+5
-1
.gitignore
+5
-1
.gitignore
···
1
1
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
2
.env
3
3
# dependencies
4
-
/node_modules
4
+
node_modules
5
+
**/node_modules
5
6
/.pnp
6
7
.pnp.js
7
8
···
17
18
# production
18
19
/build
19
20
/result
21
+
dist
22
+
**/dist
23
+
*.tsbuildinfo
20
24
21
25
# misc
22
26
.DS_Store
+839
apps/hosting-service/src/lib/file-serving.ts
+839
apps/hosting-service/src/lib/file-serving.ts
···
1
+
/**
2
+
* Core file serving logic for the hosting service
3
+
* Handles file retrieval, caching, redirects, and HTML rewriting
4
+
*/
5
+
6
+
import { readFile } from 'fs/promises';
7
+
import { lookup } from 'mime-types';
8
+
import type { Record as WispSettings } from '@wisp/lexicons/types/place/wisp/settings';
9
+
import { shouldCompressMimeType } from '@wisp/atproto-utils/compression';
10
+
import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, isSiteBeingCached } from './cache';
11
+
import { getCachedFilePath, getCachedSettings } from './utils';
12
+
import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString } from './redirects';
13
+
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter';
14
+
import { generate404Page, generateDirectoryListing, siteUpdatingResponse } from './page-generators';
15
+
import { getIndexFiles, applyCustomHeaders, fileExists } from './request-utils';
16
+
import { getRedirectRulesFromCache, setRedirectRulesInCache } from './site-cache';
17
+
18
+
/**
19
+
* Helper to serve files from cache (for custom domains and subdomains)
20
+
*/
21
+
export async function serveFromCache(
22
+
did: string,
23
+
rkey: string,
24
+
filePath: string,
25
+
fullUrl?: string,
26
+
headers?: Record<string, string>
27
+
): Promise<Response> {
28
+
// Load settings for this site
29
+
const settings = await getCachedSettings(did, rkey);
30
+
const indexFiles = getIndexFiles(settings);
31
+
32
+
// Check for redirect rules first (_redirects wins over settings)
33
+
let redirectRules = getRedirectRulesFromCache(did, rkey);
34
+
35
+
if (redirectRules === undefined) {
36
+
// Load rules for the first time
37
+
redirectRules = await loadRedirectRules(did, rkey);
38
+
setRedirectRulesInCache(did, rkey, redirectRules);
39
+
}
40
+
41
+
// Apply redirect rules if any exist
42
+
if (redirectRules.length > 0) {
43
+
const requestPath = '/' + (filePath || '');
44
+
const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
45
+
const cookies = parseCookies(headers?.['cookie']);
46
+
47
+
const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
48
+
queryParams,
49
+
headers,
50
+
cookies,
51
+
});
52
+
53
+
if (redirectMatch) {
54
+
const { rule, targetPath, status } = redirectMatch;
55
+
56
+
// If not forced, check if the requested file exists before redirecting
57
+
if (!rule.force) {
58
+
// Build the expected file path
59
+
let checkPath: string = filePath || indexFiles[0] || 'index.html';
60
+
if (checkPath.endsWith('/')) {
61
+
checkPath += indexFiles[0] || 'index.html';
62
+
}
63
+
64
+
const cachedFile = getCachedFilePath(did, rkey, checkPath);
65
+
const fileExistsOnDisk = await fileExists(cachedFile);
66
+
67
+
// If file exists and redirect is not forced, serve the file normally
68
+
if (fileExistsOnDisk) {
69
+
return serveFileInternal(did, rkey, filePath, settings);
70
+
}
71
+
}
72
+
73
+
// Handle different status codes
74
+
if (status === 200) {
75
+
// Rewrite: serve different content but keep URL the same
76
+
// Remove leading slash for internal path resolution
77
+
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
78
+
return serveFileInternal(did, rkey, rewritePath, settings);
79
+
} else if (status === 301 || status === 302) {
80
+
// External redirect: change the URL
81
+
return new Response(null, {
82
+
status,
83
+
headers: {
84
+
'Location': targetPath,
85
+
'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
86
+
},
87
+
});
88
+
} else if (status === 404) {
89
+
// Custom 404 page from _redirects (wins over settings.custom404)
90
+
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
91
+
const response = await serveFileInternal(did, rkey, custom404Path, settings);
92
+
// Override status to 404
93
+
return new Response(response.body, {
94
+
status: 404,
95
+
headers: response.headers,
96
+
});
97
+
}
98
+
}
99
+
}
100
+
101
+
// No redirect matched, serve normally with settings
102
+
return serveFileInternal(did, rkey, filePath, settings);
103
+
}
104
+
105
+
/**
106
+
* Internal function to serve a file (used by both normal serving and rewrites)
107
+
*/
108
+
export async function serveFileInternal(
109
+
did: string,
110
+
rkey: string,
111
+
filePath: string,
112
+
settings: WispSettings | null = null
113
+
): Promise<Response> {
114
+
// Check if site is currently being cached - if so, return updating response
115
+
if (isSiteBeingCached(did, rkey)) {
116
+
return siteUpdatingResponse();
117
+
}
118
+
119
+
const indexFiles = getIndexFiles(settings);
120
+
121
+
// Normalize the request path (keep empty for root, remove trailing slash for others)
122
+
let requestPath = filePath || '';
123
+
if (requestPath.endsWith('/') && requestPath.length > 1) {
124
+
requestPath = requestPath.slice(0, -1);
125
+
}
126
+
127
+
// Check if this path is a directory first
128
+
const directoryPath = getCachedFilePath(did, rkey, requestPath);
129
+
if (await fileExists(directoryPath)) {
130
+
const { stat, readdir } = await import('fs/promises');
131
+
try {
132
+
const stats = await stat(directoryPath);
133
+
if (stats.isDirectory()) {
134
+
// It's a directory, try each index file in order
135
+
for (const indexFile of indexFiles) {
136
+
const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile;
137
+
const indexFilePath = getCachedFilePath(did, rkey, indexPath);
138
+
if (await fileExists(indexFilePath)) {
139
+
return serveFileInternal(did, rkey, indexPath, settings);
140
+
}
141
+
}
142
+
// No index file found - check if directory listing is enabled
143
+
if (settings?.directoryListing) {
144
+
const { stat } = await import('fs/promises');
145
+
const entries = await readdir(directoryPath);
146
+
// Filter out .meta files and other hidden files
147
+
const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json');
148
+
149
+
// Check which entries are directories
150
+
const entriesWithType = await Promise.all(
151
+
visibleEntries.map(async (name) => {
152
+
try {
153
+
const entryPath = `${directoryPath}/${name}`;
154
+
const stats = await stat(entryPath);
155
+
return { name, isDirectory: stats.isDirectory() };
156
+
} catch {
157
+
return { name, isDirectory: false };
158
+
}
159
+
})
160
+
);
161
+
162
+
const html = generateDirectoryListing(requestPath, entriesWithType);
163
+
return new Response(html, {
164
+
headers: {
165
+
'Content-Type': 'text/html; charset=utf-8',
166
+
'Cache-Control': 'public, max-age=300',
167
+
},
168
+
});
169
+
}
170
+
// Fall through to 404/SPA handling
171
+
}
172
+
} catch (err) {
173
+
// If stat fails, continue with normal flow
174
+
}
175
+
}
176
+
177
+
// Not a directory, try to serve as a file
178
+
const fileRequestPath: string = requestPath || indexFiles[0] || 'index.html';
179
+
const cacheKey = getCacheKey(did, rkey, fileRequestPath);
180
+
const cachedFile = getCachedFilePath(did, rkey, fileRequestPath);
181
+
182
+
// Check in-memory cache first
183
+
let content = fileCache.get(cacheKey);
184
+
let meta = metadataCache.get(cacheKey);
185
+
186
+
if (!content && await fileExists(cachedFile)) {
187
+
// Read from disk and cache
188
+
content = await readFile(cachedFile);
189
+
fileCache.set(cacheKey, content, content.length);
190
+
191
+
const metaFile = `${cachedFile}.meta`;
192
+
if (await fileExists(metaFile)) {
193
+
const metaJson = await readFile(metaFile, 'utf-8');
194
+
meta = JSON.parse(metaJson);
195
+
metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length);
196
+
}
197
+
}
198
+
199
+
if (content) {
200
+
// Build headers with caching
201
+
const headers: Record<string, string> = {};
202
+
203
+
if (meta && meta.encoding === 'gzip' && meta.mimeType) {
204
+
const shouldServeCompressed = shouldCompressMimeType(meta.mimeType);
205
+
206
+
if (!shouldServeCompressed) {
207
+
// Verify content is actually gzipped before attempting decompression
208
+
const isGzipped = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
209
+
if (isGzipped) {
210
+
const { gunzipSync } = await import('zlib');
211
+
const decompressed = gunzipSync(content);
212
+
headers['Content-Type'] = meta.mimeType;
213
+
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
214
+
applyCustomHeaders(headers, fileRequestPath, settings);
215
+
return new Response(decompressed, { headers });
216
+
} else {
217
+
// Meta says gzipped but content isn't - serve as-is
218
+
console.warn(`File ${filePath} has gzip encoding in meta but content lacks gzip magic bytes`);
219
+
headers['Content-Type'] = meta.mimeType;
220
+
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
221
+
applyCustomHeaders(headers, fileRequestPath, settings);
222
+
return new Response(content, { headers });
223
+
}
224
+
}
225
+
226
+
headers['Content-Type'] = meta.mimeType;
227
+
headers['Content-Encoding'] = 'gzip';
228
+
headers['Cache-Control'] = meta.mimeType.startsWith('text/html')
229
+
? 'public, max-age=300'
230
+
: 'public, max-age=31536000, immutable';
231
+
applyCustomHeaders(headers, fileRequestPath, settings);
232
+
return new Response(content, { headers });
233
+
}
234
+
235
+
// Non-compressed files
236
+
const mimeType = lookup(cachedFile) || 'application/octet-stream';
237
+
headers['Content-Type'] = mimeType;
238
+
headers['Cache-Control'] = mimeType.startsWith('text/html')
239
+
? 'public, max-age=300'
240
+
: 'public, max-age=31536000, immutable';
241
+
applyCustomHeaders(headers, fileRequestPath, settings);
242
+
return new Response(content, { headers });
243
+
}
244
+
245
+
// Try index files for directory-like paths
246
+
if (!fileRequestPath.includes('.')) {
247
+
for (const indexFileName of indexFiles) {
248
+
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
249
+
const indexCacheKey = getCacheKey(did, rkey, indexPath);
250
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
251
+
252
+
let indexContent = fileCache.get(indexCacheKey);
253
+
let indexMeta = metadataCache.get(indexCacheKey);
254
+
255
+
if (!indexContent && await fileExists(indexFile)) {
256
+
indexContent = await readFile(indexFile);
257
+
fileCache.set(indexCacheKey, indexContent, indexContent.length);
258
+
259
+
const indexMetaFile = `${indexFile}.meta`;
260
+
if (await fileExists(indexMetaFile)) {
261
+
const metaJson = await readFile(indexMetaFile, 'utf-8');
262
+
indexMeta = JSON.parse(metaJson);
263
+
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
264
+
}
265
+
}
266
+
267
+
if (indexContent) {
268
+
const headers: Record<string, string> = {
269
+
'Content-Type': 'text/html; charset=utf-8',
270
+
'Cache-Control': 'public, max-age=300',
271
+
};
272
+
273
+
if (indexMeta && indexMeta.encoding === 'gzip') {
274
+
headers['Content-Encoding'] = 'gzip';
275
+
}
276
+
277
+
applyCustomHeaders(headers, indexPath, settings);
278
+
return new Response(indexContent, { headers });
279
+
}
280
+
}
281
+
}
282
+
283
+
// Try clean URLs: /about -> /about.html
284
+
if (settings?.cleanUrls && !fileRequestPath.includes('.')) {
285
+
const htmlPath = `${fileRequestPath}.html`;
286
+
const htmlFile = getCachedFilePath(did, rkey, htmlPath);
287
+
if (await fileExists(htmlFile)) {
288
+
return serveFileInternal(did, rkey, htmlPath, settings);
289
+
}
290
+
291
+
// Also try /about/index.html
292
+
for (const indexFileName of indexFiles) {
293
+
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
294
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
295
+
if (await fileExists(indexFile)) {
296
+
return serveFileInternal(did, rkey, indexPath, settings);
297
+
}
298
+
}
299
+
}
300
+
301
+
// SPA mode: serve SPA file for all non-existing routes (wins over custom404 but loses to _redirects)
302
+
if (settings?.spaMode) {
303
+
const spaFile = settings.spaMode;
304
+
const spaFilePath = getCachedFilePath(did, rkey, spaFile);
305
+
if (await fileExists(spaFilePath)) {
306
+
return serveFileInternal(did, rkey, spaFile, settings);
307
+
}
308
+
}
309
+
310
+
// Custom 404: serve custom 404 file if configured (wins conflict battle)
311
+
if (settings?.custom404) {
312
+
const custom404File = settings.custom404;
313
+
const custom404Path = getCachedFilePath(did, rkey, custom404File);
314
+
if (await fileExists(custom404Path)) {
315
+
const response: Response = await serveFileInternal(did, rkey, custom404File, settings);
316
+
// Override status to 404
317
+
return new Response(response.body, {
318
+
status: 404,
319
+
headers: response.headers,
320
+
});
321
+
}
322
+
}
323
+
324
+
// Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html)
325
+
const auto404Pages = ['404.html', 'not_found.html'];
326
+
for (const auto404Page of auto404Pages) {
327
+
const auto404Path = getCachedFilePath(did, rkey, auto404Page);
328
+
if (await fileExists(auto404Path)) {
329
+
const response: Response = await serveFileInternal(did, rkey, auto404Page, settings);
330
+
// Override status to 404
331
+
return new Response(response.body, {
332
+
status: 404,
333
+
headers: response.headers,
334
+
});
335
+
}
336
+
}
337
+
338
+
// Directory listing fallback: if enabled, show root directory listing on 404
339
+
if (settings?.directoryListing) {
340
+
const rootPath = getCachedFilePath(did, rkey, '');
341
+
if (await fileExists(rootPath)) {
342
+
const { stat, readdir } = await import('fs/promises');
343
+
try {
344
+
const stats = await stat(rootPath);
345
+
if (stats.isDirectory()) {
346
+
const entries = await readdir(rootPath);
347
+
// Filter out .meta files and metadata
348
+
const visibleEntries = entries.filter(entry =>
349
+
!entry.endsWith('.meta') && entry !== '.metadata.json'
350
+
);
351
+
352
+
// Check which entries are directories
353
+
const entriesWithType = await Promise.all(
354
+
visibleEntries.map(async (name) => {
355
+
try {
356
+
const entryPath = `${rootPath}/${name}`;
357
+
const entryStats = await stat(entryPath);
358
+
return { name, isDirectory: entryStats.isDirectory() };
359
+
} catch {
360
+
return { name, isDirectory: false };
361
+
}
362
+
})
363
+
);
364
+
365
+
const html = generateDirectoryListing('', entriesWithType);
366
+
return new Response(html, {
367
+
status: 404,
368
+
headers: {
369
+
'Content-Type': 'text/html; charset=utf-8',
370
+
'Cache-Control': 'public, max-age=300',
371
+
},
372
+
});
373
+
}
374
+
} catch (err) {
375
+
// If directory listing fails, fall through to 404
376
+
}
377
+
}
378
+
}
379
+
380
+
// Default styled 404 page
381
+
const html = generate404Page();
382
+
return new Response(html, {
383
+
status: 404,
384
+
headers: {
385
+
'Content-Type': 'text/html; charset=utf-8',
386
+
'Cache-Control': 'public, max-age=300',
387
+
},
388
+
});
389
+
}
390
+
391
+
/**
392
+
* Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes
393
+
*/
394
+
export async function serveFromCacheWithRewrite(
395
+
did: string,
396
+
rkey: string,
397
+
filePath: string,
398
+
basePath: string,
399
+
fullUrl?: string,
400
+
headers?: Record<string, string>
401
+
): Promise<Response> {
402
+
// Load settings for this site
403
+
const settings = await getCachedSettings(did, rkey);
404
+
const indexFiles = getIndexFiles(settings);
405
+
406
+
// Check for redirect rules first (_redirects wins over settings)
407
+
let redirectRules = getRedirectRulesFromCache(did, rkey);
408
+
409
+
if (redirectRules === undefined) {
410
+
// Load rules for the first time
411
+
redirectRules = await loadRedirectRules(did, rkey);
412
+
setRedirectRulesInCache(did, rkey, redirectRules);
413
+
}
414
+
415
+
// Apply redirect rules if any exist
416
+
if (redirectRules.length > 0) {
417
+
const requestPath = '/' + (filePath || '');
418
+
const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
419
+
const cookies = parseCookies(headers?.['cookie']);
420
+
421
+
const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
422
+
queryParams,
423
+
headers,
424
+
cookies,
425
+
});
426
+
427
+
if (redirectMatch) {
428
+
const { rule, targetPath, status } = redirectMatch;
429
+
430
+
// If not forced, check if the requested file exists before redirecting
431
+
if (!rule.force) {
432
+
// Build the expected file path
433
+
let checkPath: string = filePath || indexFiles[0] || 'index.html';
434
+
if (checkPath.endsWith('/')) {
435
+
checkPath += indexFiles[0] || 'index.html';
436
+
}
437
+
438
+
const cachedFile = getCachedFilePath(did, rkey, checkPath);
439
+
const fileExistsOnDisk = await fileExists(cachedFile);
440
+
441
+
// If file exists and redirect is not forced, serve the file normally
442
+
if (fileExistsOnDisk) {
443
+
return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
444
+
}
445
+
}
446
+
447
+
// Handle different status codes
448
+
if (status === 200) {
449
+
// Rewrite: serve different content but keep URL the same
450
+
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
451
+
return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath, settings);
452
+
} else if (status === 301 || status === 302) {
453
+
// External redirect: change the URL
454
+
// For sites.wisp.place, we need to adjust the target path to include the base path
455
+
// unless it's an absolute URL
456
+
let redirectTarget = targetPath;
457
+
if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) {
458
+
redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath);
459
+
}
460
+
return new Response(null, {
461
+
status,
462
+
headers: {
463
+
'Location': redirectTarget,
464
+
'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
465
+
},
466
+
});
467
+
} else if (status === 404) {
468
+
// Custom 404 page from _redirects (wins over settings.custom404)
469
+
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
470
+
const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath, settings);
471
+
// Override status to 404
472
+
return new Response(response.body, {
473
+
status: 404,
474
+
headers: response.headers,
475
+
});
476
+
}
477
+
}
478
+
}
479
+
480
+
// No redirect matched, serve normally with settings
481
+
return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
482
+
}
483
+
484
+
/**
485
+
* Internal function to serve a file with rewriting
486
+
*/
487
+
export async function serveFileInternalWithRewrite(
488
+
did: string,
489
+
rkey: string,
490
+
filePath: string,
491
+
basePath: string,
492
+
settings: WispSettings | null = null
493
+
): Promise<Response> {
494
+
// Check if site is currently being cached - if so, return updating response
495
+
if (isSiteBeingCached(did, rkey)) {
496
+
return siteUpdatingResponse();
497
+
}
498
+
499
+
const indexFiles = getIndexFiles(settings);
500
+
501
+
// Normalize the request path (keep empty for root, remove trailing slash for others)
502
+
let requestPath = filePath || '';
503
+
if (requestPath.endsWith('/') && requestPath.length > 1) {
504
+
requestPath = requestPath.slice(0, -1);
505
+
}
506
+
507
+
// Check if this path is a directory first
508
+
const directoryPath = getCachedFilePath(did, rkey, requestPath);
509
+
if (await fileExists(directoryPath)) {
510
+
const { stat, readdir } = await import('fs/promises');
511
+
try {
512
+
const stats = await stat(directoryPath);
513
+
if (stats.isDirectory()) {
514
+
// It's a directory, try each index file in order
515
+
for (const indexFile of indexFiles) {
516
+
const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile;
517
+
const indexFilePath = getCachedFilePath(did, rkey, indexPath);
518
+
if (await fileExists(indexFilePath)) {
519
+
return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
520
+
}
521
+
}
522
+
// No index file found - check if directory listing is enabled
523
+
if (settings?.directoryListing) {
524
+
const { stat } = await import('fs/promises');
525
+
const entries = await readdir(directoryPath);
526
+
// Filter out .meta files and other hidden files
527
+
const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json');
528
+
529
+
// Check which entries are directories
530
+
const entriesWithType = await Promise.all(
531
+
visibleEntries.map(async (name) => {
532
+
try {
533
+
const entryPath = `${directoryPath}/${name}`;
534
+
const stats = await stat(entryPath);
535
+
return { name, isDirectory: stats.isDirectory() };
536
+
} catch {
537
+
return { name, isDirectory: false };
538
+
}
539
+
})
540
+
);
541
+
542
+
const html = generateDirectoryListing(requestPath, entriesWithType);
543
+
return new Response(html, {
544
+
headers: {
545
+
'Content-Type': 'text/html; charset=utf-8',
546
+
'Cache-Control': 'public, max-age=300',
547
+
},
548
+
});
549
+
}
550
+
// Fall through to 404/SPA handling
551
+
}
552
+
} catch (err) {
553
+
// If stat fails, continue with normal flow
554
+
}
555
+
}
556
+
557
+
// Not a directory, try to serve as a file
558
+
const fileRequestPath: string = requestPath || indexFiles[0] || 'index.html';
559
+
const cacheKey = getCacheKey(did, rkey, fileRequestPath);
560
+
const cachedFile = getCachedFilePath(did, rkey, fileRequestPath);
561
+
562
+
// Check for rewritten HTML in cache first (if it's HTML)
563
+
const mimeTypeGuess = lookup(fileRequestPath) || 'application/octet-stream';
564
+
if (isHtmlContent(fileRequestPath, mimeTypeGuess)) {
565
+
const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
566
+
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
567
+
if (rewrittenContent) {
568
+
const headers: Record<string, string> = {
569
+
'Content-Type': 'text/html; charset=utf-8',
570
+
'Content-Encoding': 'gzip',
571
+
'Cache-Control': 'public, max-age=300',
572
+
};
573
+
applyCustomHeaders(headers, fileRequestPath, settings);
574
+
return new Response(rewrittenContent, { headers });
575
+
}
576
+
}
577
+
578
+
// Check in-memory file cache
579
+
let content = fileCache.get(cacheKey);
580
+
let meta = metadataCache.get(cacheKey);
581
+
582
+
if (!content && await fileExists(cachedFile)) {
583
+
// Read from disk and cache
584
+
content = await readFile(cachedFile);
585
+
fileCache.set(cacheKey, content, content.length);
586
+
587
+
const metaFile = `${cachedFile}.meta`;
588
+
if (await fileExists(metaFile)) {
589
+
const metaJson = await readFile(metaFile, 'utf-8');
590
+
meta = JSON.parse(metaJson);
591
+
metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length);
592
+
}
593
+
}
594
+
595
+
if (content) {
596
+
const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream';
597
+
const isGzipped = meta?.encoding === 'gzip';
598
+
599
+
// Check if this is HTML content that needs rewriting
600
+
if (isHtmlContent(fileRequestPath, mimeType)) {
601
+
let htmlContent: string;
602
+
if (isGzipped) {
603
+
// Verify content is actually gzipped
604
+
const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
605
+
if (hasGzipMagic) {
606
+
const { gunzipSync } = await import('zlib');
607
+
htmlContent = gunzipSync(content).toString('utf-8');
608
+
} else {
609
+
console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
610
+
htmlContent = content.toString('utf-8');
611
+
}
612
+
} else {
613
+
htmlContent = content.toString('utf-8');
614
+
}
615
+
const rewritten = rewriteHtmlPaths(htmlContent, basePath, fileRequestPath);
616
+
617
+
// Recompress and cache the rewritten HTML
618
+
const { gzipSync } = await import('zlib');
619
+
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
620
+
621
+
const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
622
+
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
623
+
624
+
const htmlHeaders: Record<string, string> = {
625
+
'Content-Type': 'text/html; charset=utf-8',
626
+
'Content-Encoding': 'gzip',
627
+
'Cache-Control': 'public, max-age=300',
628
+
};
629
+
applyCustomHeaders(htmlHeaders, fileRequestPath, settings);
630
+
return new Response(recompressed, { headers: htmlHeaders });
631
+
}
632
+
633
+
// Non-HTML files: serve as-is
634
+
const headers: Record<string, string> = {
635
+
'Content-Type': mimeType,
636
+
'Cache-Control': 'public, max-age=31536000, immutable',
637
+
};
638
+
639
+
if (isGzipped) {
640
+
const shouldServeCompressed = shouldCompressMimeType(mimeType);
641
+
if (!shouldServeCompressed) {
642
+
// Verify content is actually gzipped
643
+
const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
644
+
if (hasGzipMagic) {
645
+
const { gunzipSync } = await import('zlib');
646
+
const decompressed = gunzipSync(content);
647
+
applyCustomHeaders(headers, fileRequestPath, settings);
648
+
return new Response(decompressed, { headers });
649
+
} else {
650
+
console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
651
+
applyCustomHeaders(headers, fileRequestPath, settings);
652
+
return new Response(content, { headers });
653
+
}
654
+
}
655
+
headers['Content-Encoding'] = 'gzip';
656
+
}
657
+
658
+
applyCustomHeaders(headers, fileRequestPath, settings);
659
+
return new Response(content, { headers });
660
+
}
661
+
662
+
// Try index files for directory-like paths
663
+
if (!fileRequestPath.includes('.')) {
664
+
for (const indexFileName of indexFiles) {
665
+
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
666
+
const indexCacheKey = getCacheKey(did, rkey, indexPath);
667
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
668
+
669
+
// Check for rewritten index file in cache
670
+
const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`);
671
+
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
672
+
if (rewrittenContent) {
673
+
const headers: Record<string, string> = {
674
+
'Content-Type': 'text/html; charset=utf-8',
675
+
'Content-Encoding': 'gzip',
676
+
'Cache-Control': 'public, max-age=300',
677
+
};
678
+
applyCustomHeaders(headers, indexPath, settings);
679
+
return new Response(rewrittenContent, { headers });
680
+
}
681
+
682
+
let indexContent = fileCache.get(indexCacheKey);
683
+
let indexMeta = metadataCache.get(indexCacheKey);
684
+
685
+
if (!indexContent && await fileExists(indexFile)) {
686
+
indexContent = await readFile(indexFile);
687
+
fileCache.set(indexCacheKey, indexContent, indexContent.length);
688
+
689
+
const indexMetaFile = `${indexFile}.meta`;
690
+
if (await fileExists(indexMetaFile)) {
691
+
const metaJson = await readFile(indexMetaFile, 'utf-8');
692
+
indexMeta = JSON.parse(metaJson);
693
+
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
694
+
}
695
+
}
696
+
697
+
if (indexContent) {
698
+
const isGzipped = indexMeta?.encoding === 'gzip';
699
+
700
+
let htmlContent: string;
701
+
if (isGzipped) {
702
+
// Verify content is actually gzipped
703
+
const hasGzipMagic = indexContent.length >= 2 && indexContent[0] === 0x1f && indexContent[1] === 0x8b;
704
+
if (hasGzipMagic) {
705
+
const { gunzipSync } = await import('zlib');
706
+
htmlContent = gunzipSync(indexContent).toString('utf-8');
707
+
} else {
708
+
console.warn(`Index file marked as gzipped but lacks magic bytes, serving as-is`);
709
+
htmlContent = indexContent.toString('utf-8');
710
+
}
711
+
} else {
712
+
htmlContent = indexContent.toString('utf-8');
713
+
}
714
+
const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath);
715
+
716
+
const { gzipSync } = await import('zlib');
717
+
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
718
+
719
+
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
720
+
721
+
const headers: Record<string, string> = {
722
+
'Content-Type': 'text/html; charset=utf-8',
723
+
'Content-Encoding': 'gzip',
724
+
'Cache-Control': 'public, max-age=300',
725
+
};
726
+
applyCustomHeaders(headers, indexPath, settings);
727
+
return new Response(recompressed, { headers });
728
+
}
729
+
}
730
+
}
731
+
732
+
// Try clean URLs: /about -> /about.html
733
+
if (settings?.cleanUrls && !fileRequestPath.includes('.')) {
734
+
const htmlPath = `${fileRequestPath}.html`;
735
+
const htmlFile = getCachedFilePath(did, rkey, htmlPath);
736
+
if (await fileExists(htmlFile)) {
737
+
return serveFileInternalWithRewrite(did, rkey, htmlPath, basePath, settings);
738
+
}
739
+
740
+
// Also try /about/index.html
741
+
for (const indexFileName of indexFiles) {
742
+
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
743
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
744
+
if (await fileExists(indexFile)) {
745
+
return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
746
+
}
747
+
}
748
+
}
749
+
750
+
// SPA mode: serve SPA file for all non-existing routes
751
+
if (settings?.spaMode) {
752
+
const spaFile = settings.spaMode;
753
+
const spaFilePath = getCachedFilePath(did, rkey, spaFile);
754
+
if (await fileExists(spaFilePath)) {
755
+
return serveFileInternalWithRewrite(did, rkey, spaFile, basePath, settings);
756
+
}
757
+
}
758
+
759
+
// Custom 404: serve custom 404 file if configured (wins conflict battle)
760
+
if (settings?.custom404) {
761
+
const custom404File = settings.custom404;
762
+
const custom404Path = getCachedFilePath(did, rkey, custom404File);
763
+
if (await fileExists(custom404Path)) {
764
+
const response: Response = await serveFileInternalWithRewrite(did, rkey, custom404File, basePath, settings);
765
+
// Override status to 404
766
+
return new Response(response.body, {
767
+
status: 404,
768
+
headers: response.headers,
769
+
});
770
+
}
771
+
}
772
+
773
+
// Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html)
774
+
const auto404Pages = ['404.html', 'not_found.html'];
775
+
for (const auto404Page of auto404Pages) {
776
+
const auto404Path = getCachedFilePath(did, rkey, auto404Page);
777
+
if (await fileExists(auto404Path)) {
778
+
const response: Response = await serveFileInternalWithRewrite(did, rkey, auto404Page, basePath, settings);
779
+
// Override status to 404
780
+
return new Response(response.body, {
781
+
status: 404,
782
+
headers: response.headers,
783
+
});
784
+
}
785
+
}
786
+
787
+
// Directory listing fallback: if enabled, show root directory listing on 404
788
+
if (settings?.directoryListing) {
789
+
const rootPath = getCachedFilePath(did, rkey, '');
790
+
if (await fileExists(rootPath)) {
791
+
const { stat, readdir } = await import('fs/promises');
792
+
try {
793
+
const stats = await stat(rootPath);
794
+
if (stats.isDirectory()) {
795
+
const entries = await readdir(rootPath);
796
+
// Filter out .meta files and metadata
797
+
const visibleEntries = entries.filter(entry =>
798
+
!entry.endsWith('.meta') && entry !== '.metadata.json'
799
+
);
800
+
801
+
// Check which entries are directories
802
+
const entriesWithType = await Promise.all(
803
+
visibleEntries.map(async (name) => {
804
+
try {
805
+
const entryPath = `${rootPath}/${name}`;
806
+
const entryStats = await stat(entryPath);
807
+
return { name, isDirectory: entryStats.isDirectory() };
808
+
} catch {
809
+
return { name, isDirectory: false };
810
+
}
811
+
})
812
+
);
813
+
814
+
const html = generateDirectoryListing('', entriesWithType);
815
+
return new Response(html, {
816
+
status: 404,
817
+
headers: {
818
+
'Content-Type': 'text/html; charset=utf-8',
819
+
'Cache-Control': 'public, max-age=300',
820
+
},
821
+
});
822
+
}
823
+
} catch (err) {
824
+
// If directory listing fails, fall through to 404
825
+
}
826
+
}
827
+
}
828
+
829
+
// Default styled 404 page
830
+
const html = generate404Page();
831
+
return new Response(html, {
832
+
status: 404,
833
+
headers: {
834
+
'Content-Type': 'text/html; charset=utf-8',
835
+
'Cache-Control': 'public, max-age=300',
836
+
},
837
+
});
838
+
}
839
+
+362
apps/hosting-service/src/lib/page-generators.ts
+362
apps/hosting-service/src/lib/page-generators.ts
···
1
+
/**
2
+
* HTML page generation utilities for hosting service
3
+
* Generates 404 pages, directory listings, and updating pages
4
+
*/
5
+
6
+
/**
7
+
* Generate 404 page HTML
8
+
*/
9
+
export function generate404Page(): string {
10
+
const html = `<!DOCTYPE html>
11
+
<html>
12
+
<head>
13
+
<meta charset="utf-8">
14
+
<meta name="viewport" content="width=device-width, initial-scale=1">
15
+
<title>404 - Not Found</title>
16
+
<style>
17
+
@media (prefers-color-scheme: light) {
18
+
:root {
19
+
/* Warm beige background */
20
+
--background: oklch(0.90 0.012 35);
21
+
/* Very dark brown text */
22
+
--foreground: oklch(0.18 0.01 30);
23
+
--border: oklch(0.75 0.015 30);
24
+
/* Bright pink accent for links */
25
+
--accent: oklch(0.78 0.15 345);
26
+
}
27
+
}
28
+
@media (prefers-color-scheme: dark) {
29
+
:root {
30
+
/* Slate violet background */
31
+
--background: oklch(0.23 0.015 285);
32
+
/* Light gray text */
33
+
--foreground: oklch(0.90 0.005 285);
34
+
/* Subtle borders */
35
+
--border: oklch(0.38 0.02 285);
36
+
/* Soft pink accent */
37
+
--accent: oklch(0.85 0.08 5);
38
+
}
39
+
}
40
+
body {
41
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
42
+
background: var(--background);
43
+
color: var(--foreground);
44
+
padding: 2rem;
45
+
max-width: 800px;
46
+
margin: 0 auto;
47
+
display: flex;
48
+
flex-direction: column;
49
+
min-height: 100vh;
50
+
justify-content: center;
51
+
align-items: center;
52
+
text-align: center;
53
+
}
54
+
h1 {
55
+
font-size: 6rem;
56
+
margin: 0;
57
+
font-weight: 700;
58
+
line-height: 1;
59
+
}
60
+
h2 {
61
+
font-size: 1.5rem;
62
+
margin: 1rem 0 2rem;
63
+
font-weight: 400;
64
+
opacity: 0.8;
65
+
}
66
+
p {
67
+
font-size: 1rem;
68
+
opacity: 0.7;
69
+
margin-bottom: 2rem;
70
+
}
71
+
a {
72
+
color: var(--accent);
73
+
text-decoration: none;
74
+
font-size: 1rem;
75
+
}
76
+
a:hover {
77
+
text-decoration: underline;
78
+
}
79
+
footer {
80
+
margin-top: 2rem;
81
+
padding-top: 1.5rem;
82
+
border-top: 1px solid var(--border);
83
+
text-align: center;
84
+
font-size: 0.875rem;
85
+
opacity: 0.7;
86
+
color: var(--foreground);
87
+
}
88
+
footer a {
89
+
color: var(--accent);
90
+
text-decoration: none;
91
+
display: inline;
92
+
}
93
+
footer a:hover {
94
+
text-decoration: underline;
95
+
}
96
+
</style>
97
+
</head>
98
+
<body>
99
+
<div>
100
+
<h1>404</h1>
101
+
<h2>Page not found</h2>
102
+
<p>The page you're looking for doesn't exist.</p>
103
+
<a href="/">← Back to home</a>
104
+
</div>
105
+
<footer>
106
+
Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a>
107
+
</footer>
108
+
</body>
109
+
</html>`;
110
+
return html;
111
+
}
112
+
113
+
/**
114
+
* Generate directory listing HTML
115
+
*/
116
+
export function generateDirectoryListing(path: string, entries: Array<{name: string, isDirectory: boolean}>): string {
117
+
const title = path || 'Index';
118
+
119
+
// Sort: directories first, then files, alphabetically within each group
120
+
const sortedEntries = [...entries].sort((a, b) => {
121
+
if (a.isDirectory && !b.isDirectory) return -1;
122
+
if (!a.isDirectory && b.isDirectory) return 1;
123
+
return a.name.localeCompare(b.name);
124
+
});
125
+
126
+
const html = `<!DOCTYPE html>
127
+
<html>
128
+
<head>
129
+
<meta charset="utf-8">
130
+
<meta name="viewport" content="width=device-width, initial-scale=1">
131
+
<title>Index of /${path}</title>
132
+
<style>
133
+
@media (prefers-color-scheme: light) {
134
+
:root {
135
+
/* Warm beige background */
136
+
--background: oklch(0.90 0.012 35);
137
+
/* Very dark brown text */
138
+
--foreground: oklch(0.18 0.01 30);
139
+
--border: oklch(0.75 0.015 30);
140
+
/* Bright pink accent for links */
141
+
--accent: oklch(0.78 0.15 345);
142
+
/* Lavender for folders */
143
+
--folder: oklch(0.60 0.12 295);
144
+
--icon: oklch(0.28 0.01 30);
145
+
}
146
+
}
147
+
@media (prefers-color-scheme: dark) {
148
+
:root {
149
+
/* Slate violet background */
150
+
--background: oklch(0.23 0.015 285);
151
+
/* Light gray text */
152
+
--foreground: oklch(0.90 0.005 285);
153
+
/* Subtle borders */
154
+
--border: oklch(0.38 0.02 285);
155
+
/* Soft pink accent */
156
+
--accent: oklch(0.85 0.08 5);
157
+
/* Lavender for folders */
158
+
--folder: oklch(0.70 0.10 295);
159
+
--icon: oklch(0.85 0.005 285);
160
+
}
161
+
}
162
+
body {
163
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
164
+
background: var(--background);
165
+
color: var(--foreground);
166
+
padding: 2rem;
167
+
max-width: 800px;
168
+
margin: 0 auto;
169
+
}
170
+
h1 {
171
+
font-size: 1.5rem;
172
+
margin-bottom: 2rem;
173
+
padding-bottom: 0.5rem;
174
+
border-bottom: 1px solid var(--border);
175
+
}
176
+
ul {
177
+
list-style: none;
178
+
padding: 0;
179
+
}
180
+
li {
181
+
padding: 0.5rem 0;
182
+
border-bottom: 1px solid var(--border);
183
+
}
184
+
li:last-child {
185
+
border-bottom: none;
186
+
}
187
+
li a {
188
+
color: var(--accent);
189
+
text-decoration: none;
190
+
display: flex;
191
+
align-items: center;
192
+
gap: 0.75rem;
193
+
}
194
+
li a:hover {
195
+
text-decoration: underline;
196
+
}
197
+
.folder {
198
+
color: var(--folder);
199
+
font-weight: 600;
200
+
}
201
+
.file {
202
+
color: var(--accent);
203
+
}
204
+
.folder::before,
205
+
.file::before,
206
+
.parent::before {
207
+
content: "";
208
+
display: inline-block;
209
+
width: 1.25em;
210
+
height: 1.25em;
211
+
background-color: var(--icon);
212
+
flex-shrink: 0;
213
+
-webkit-mask-size: contain;
214
+
mask-size: contain;
215
+
-webkit-mask-repeat: no-repeat;
216
+
mask-repeat: no-repeat;
217
+
-webkit-mask-position: center;
218
+
mask-position: center;
219
+
}
220
+
.folder::before {
221
+
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>');
222
+
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>');
223
+
}
224
+
.file::before {
225
+
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>');
226
+
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>');
227
+
}
228
+
.parent::before {
229
+
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>');
230
+
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>');
231
+
}
232
+
footer {
233
+
margin-top: 2rem;
234
+
padding-top: 1.5rem;
235
+
border-top: 1px solid var(--border);
236
+
text-align: center;
237
+
font-size: 0.875rem;
238
+
opacity: 0.7;
239
+
color: var(--foreground);
240
+
}
241
+
footer a {
242
+
color: var(--accent);
243
+
text-decoration: none;
244
+
display: inline;
245
+
}
246
+
footer a:hover {
247
+
text-decoration: underline;
248
+
}
249
+
</style>
250
+
</head>
251
+
<body>
252
+
<h1>Index of /${path}</h1>
253
+
<ul>
254
+
${path ? '<li><a href="../" class="parent">../</a></li>' : ''}
255
+
${sortedEntries.map(e =>
256
+
`<li><a href="${e.name}${e.isDirectory ? '/' : ''}" class="${e.isDirectory ? 'folder' : 'file'}">${e.name}${e.isDirectory ? '/' : ''}</a></li>`
257
+
).join('\n ')}
258
+
</ul>
259
+
<footer>
260
+
Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a>
261
+
</footer>
262
+
</body>
263
+
</html>`;
264
+
return html;
265
+
}
266
+
267
+
/**
268
+
* Return a response indicating the site is being updated
269
+
*/
270
+
export function generateSiteUpdatingPage(): string {
271
+
const html = `<!DOCTYPE html>
272
+
<html>
273
+
<head>
274
+
<meta charset="utf-8">
275
+
<meta name="viewport" content="width=device-width, initial-scale=1">
276
+
<title>Site Updating</title>
277
+
<style>
278
+
@media (prefers-color-scheme: light) {
279
+
:root {
280
+
--background: oklch(0.90 0.012 35);
281
+
--foreground: oklch(0.18 0.01 30);
282
+
--primary: oklch(0.35 0.02 35);
283
+
--accent: oklch(0.78 0.15 345);
284
+
}
285
+
}
286
+
@media (prefers-color-scheme: dark) {
287
+
:root {
288
+
--background: oklch(0.23 0.015 285);
289
+
--foreground: oklch(0.90 0.005 285);
290
+
--primary: oklch(0.70 0.10 295);
291
+
--accent: oklch(0.85 0.08 5);
292
+
}
293
+
}
294
+
body {
295
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
296
+
display: flex;
297
+
align-items: center;
298
+
justify-content: center;
299
+
min-height: 100vh;
300
+
margin: 0;
301
+
background: var(--background);
302
+
color: var(--foreground);
303
+
}
304
+
.container {
305
+
text-align: center;
306
+
padding: 2rem;
307
+
max-width: 500px;
308
+
}
309
+
h1 {
310
+
font-size: 2.5rem;
311
+
margin-bottom: 1rem;
312
+
font-weight: 600;
313
+
color: var(--primary);
314
+
}
315
+
p {
316
+
font-size: 1.25rem;
317
+
opacity: 0.8;
318
+
margin-bottom: 2rem;
319
+
color: var(--foreground);
320
+
}
321
+
.spinner {
322
+
border: 4px solid var(--accent);
323
+
border-radius: 50%;
324
+
border-top: 4px solid var(--primary);
325
+
width: 40px;
326
+
height: 40px;
327
+
animation: spin 1s linear infinite;
328
+
margin: 0 auto;
329
+
}
330
+
@keyframes spin {
331
+
0% { transform: rotate(0deg); }
332
+
100% { transform: rotate(360deg); }
333
+
}
334
+
</style>
335
+
<meta http-equiv="refresh" content="3">
336
+
</head>
337
+
<body>
338
+
<div class="container">
339
+
<h1>Site Updating</h1>
340
+
<p>This site is undergoing an update right now. Check back in a moment...</p>
341
+
<div class="spinner"></div>
342
+
</div>
343
+
</body>
344
+
</html>`;
345
+
346
+
return html;
347
+
}
348
+
349
+
/**
350
+
* Create a Response for site updating
351
+
*/
352
+
export function siteUpdatingResponse(): Response {
353
+
return new Response(generateSiteUpdatingPage(), {
354
+
status: 503,
355
+
headers: {
356
+
'Content-Type': 'text/html; charset=utf-8',
357
+
'Cache-Control': 'no-store, no-cache, must-revalidate',
358
+
'Retry-After': '3',
359
+
},
360
+
});
361
+
}
362
+
+96
apps/hosting-service/src/lib/request-utils.ts
+96
apps/hosting-service/src/lib/request-utils.ts
···
1
+
/**
2
+
* Request utilities for validation and helper functions
3
+
*/
4
+
5
+
import type { Record as WispSettings } from '@wisp/lexicons/types/place/wisp/settings';
6
+
import { access } from 'fs/promises';
7
+
8
+
/**
9
+
* Default index file names to check for directory requests
10
+
* Will be checked in order until one is found
11
+
*/
12
+
export const DEFAULT_INDEX_FILES = ['index.html', 'index.htm'];
13
+
14
+
/**
15
+
* Get index files list from settings or use defaults
16
+
*/
17
+
export function getIndexFiles(settings: WispSettings | null): string[] {
18
+
if (settings?.indexFiles && settings.indexFiles.length > 0) {
19
+
return settings.indexFiles;
20
+
}
21
+
return DEFAULT_INDEX_FILES;
22
+
}
23
+
24
+
/**
25
+
* Match a file path against a glob pattern
26
+
* Supports * wildcard and basic path matching
27
+
*/
28
+
export function matchGlob(path: string, pattern: string): boolean {
29
+
// Normalize paths
30
+
const normalizedPath = path.startsWith('/') ? path : '/' + path;
31
+
const normalizedPattern = pattern.startsWith('/') ? pattern : '/' + pattern;
32
+
33
+
// Convert glob pattern to regex
34
+
const regexPattern = normalizedPattern
35
+
.replace(/\./g, '\\.')
36
+
.replace(/\*/g, '.*')
37
+
.replace(/\?/g, '.');
38
+
39
+
const regex = new RegExp('^' + regexPattern + '$');
40
+
return regex.test(normalizedPath);
41
+
}
42
+
43
+
/**
44
+
* Apply custom headers from settings to response headers
45
+
*/
46
+
export function applyCustomHeaders(headers: Record<string, string>, filePath: string, settings: WispSettings | null) {
47
+
if (!settings?.headers || settings.headers.length === 0) return;
48
+
49
+
for (const customHeader of settings.headers) {
50
+
// If path glob is specified, check if it matches
51
+
if (customHeader.path) {
52
+
if (!matchGlob(filePath, customHeader.path)) {
53
+
continue;
54
+
}
55
+
}
56
+
// Apply the header
57
+
headers[customHeader.name] = customHeader.value;
58
+
}
59
+
}
60
+
61
+
/**
62
+
* Validate site name (rkey) to prevent injection attacks
63
+
* Must match AT Protocol rkey format
64
+
*/
65
+
export function isValidRkey(rkey: string): boolean {
66
+
if (!rkey || typeof rkey !== 'string') return false;
67
+
if (rkey.length < 1 || rkey.length > 512) return false;
68
+
if (rkey === '.' || rkey === '..') return false;
69
+
if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false;
70
+
const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/;
71
+
return validRkeyPattern.test(rkey);
72
+
}
73
+
74
+
/**
75
+
* Async file existence check
76
+
*/
77
+
export async function fileExists(path: string): Promise<boolean> {
78
+
try {
79
+
await access(path);
80
+
return true;
81
+
} catch {
82
+
return false;
83
+
}
84
+
}
85
+
86
+
/**
87
+
* Extract and normalize headers from request
88
+
*/
89
+
export function extractHeaders(rawHeaders: Headers): Record<string, string> {
90
+
const headers: Record<string, string> = {};
91
+
rawHeaders.forEach((value, key) => {
92
+
headers[key.toLowerCase()] = value;
93
+
});
94
+
return headers;
95
+
}
96
+
+79
apps/hosting-service/src/lib/site-cache.ts
+79
apps/hosting-service/src/lib/site-cache.ts
···
1
+
/**
2
+
* Site caching management utilities
3
+
*/
4
+
5
+
import { createLogger } from '@wisp/observability';
6
+
import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils';
7
+
import { markSiteAsBeingCached, unmarkSiteAsBeingCached } from './cache';
8
+
import type { RedirectRule } from './redirects';
9
+
10
+
const logger = createLogger('hosting-service');
11
+
12
+
// Cache for redirect rules (per site)
13
+
const redirectRulesCache = new Map<string, RedirectRule[]>();
14
+
15
+
/**
16
+
* Clear redirect rules cache for a specific site
17
+
* Should be called when a site is updated/recached
18
+
*/
19
+
export function clearRedirectRulesCache(did: string, rkey: string) {
20
+
const cacheKey = `${did}:${rkey}`;
21
+
redirectRulesCache.delete(cacheKey);
22
+
}
23
+
24
+
/**
25
+
* Get redirect rules from cache
26
+
*/
27
+
export function getRedirectRulesFromCache(did: string, rkey: string): RedirectRule[] | undefined {
28
+
const cacheKey = `${did}:${rkey}`;
29
+
return redirectRulesCache.get(cacheKey);
30
+
}
31
+
32
+
/**
33
+
* Set redirect rules in cache
34
+
*/
35
+
export function setRedirectRulesInCache(did: string, rkey: string, rules: RedirectRule[]) {
36
+
const cacheKey = `${did}:${rkey}`;
37
+
redirectRulesCache.set(cacheKey, rules);
38
+
}
39
+
40
+
/**
41
+
* Helper to ensure site is cached
42
+
* Returns true if site is successfully cached, false otherwise
43
+
*/
44
+
export async function ensureSiteCached(did: string, rkey: string): Promise<boolean> {
45
+
if (isCached(did, rkey)) {
46
+
return true;
47
+
}
48
+
49
+
// Fetch and cache the site
50
+
const siteData = await fetchSiteRecord(did, rkey);
51
+
if (!siteData) {
52
+
logger.error('Site record not found', null, { did, rkey });
53
+
return false;
54
+
}
55
+
56
+
const pdsEndpoint = await getPdsForDid(did);
57
+
if (!pdsEndpoint) {
58
+
logger.error('PDS not found for DID', null, { did });
59
+
return false;
60
+
}
61
+
62
+
// Mark site as being cached to prevent serving stale content during update
63
+
markSiteAsBeingCached(did, rkey);
64
+
65
+
try {
66
+
await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid);
67
+
// Clear redirect rules cache since the site was updated
68
+
clearRedirectRulesCache(did, rkey);
69
+
logger.info('Site cached successfully', { did, rkey });
70
+
return true;
71
+
} catch (err) {
72
+
logger.error('Failed to cache site', err, { did, rkey });
73
+
return false;
74
+
} finally {
75
+
// Always unmark, even if caching fails
76
+
unmarkSiteAsBeingCached(did, rkey);
77
+
}
78
+
}
79
+
+234
apps/hosting-service/src/server.ts
+234
apps/hosting-service/src/server.ts
···
1
+
/**
2
+
* Main server entry point for the hosting service
3
+
* Handles routing and request dispatching
4
+
*/
5
+
6
+
import { Hono } from 'hono';
7
+
import { cors } from 'hono/cors';
8
+
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
9
+
import { resolveDid } from './lib/utils';
10
+
import { logCollector, errorTracker, metricsCollector } from '@wisp/observability';
11
+
import { observabilityMiddleware, observabilityErrorHandler } from '@wisp/observability/middleware/hono';
12
+
import { sanitizePath } from '@wisp/fs-utils';
13
+
import { isSiteBeingCached } from './lib/cache';
14
+
import { isValidRkey, extractHeaders } from './lib/request-utils';
15
+
import { siteUpdatingResponse } from './lib/page-generators';
16
+
import { ensureSiteCached } from './lib/site-cache';
17
+
import { serveFromCache, serveFromCacheWithRewrite } from './lib/file-serving';
18
+
19
+
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
20
+
21
+
const app = new Hono();
22
+
23
+
// Add CORS middleware - allow all origins for static site hosting
24
+
app.use('*', cors({
25
+
origin: '*',
26
+
allowMethods: ['GET', 'HEAD', 'OPTIONS'],
27
+
allowHeaders: ['Content-Type', 'Authorization'],
28
+
exposeHeaders: ['Content-Length', 'Content-Type', 'Content-Encoding', 'Cache-Control'],
29
+
maxAge: 86400, // 24 hours
30
+
credentials: false,
31
+
}));
32
+
33
+
// Add observability middleware
34
+
app.use('*', observabilityMiddleware('hosting-service'));
35
+
36
+
// Error handler
37
+
app.onError(observabilityErrorHandler('hosting-service'));
38
+
39
+
// Main site serving route
40
+
app.get('/*', async (c) => {
41
+
const url = new URL(c.req.url);
42
+
const hostname = c.req.header('host') || '';
43
+
const rawPath = url.pathname.replace(/^\//, '');
44
+
const path = sanitizePath(rawPath);
45
+
46
+
// Check if this is sites.wisp.place subdomain (strip port for comparison)
47
+
const hostnameWithoutPort = hostname.split(':')[0];
48
+
if (hostnameWithoutPort === `sites.${BASE_HOST}`) {
49
+
// Sanitize the path FIRST to prevent path traversal
50
+
const sanitizedFullPath = sanitizePath(rawPath);
51
+
52
+
// Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
53
+
const pathParts = sanitizedFullPath.split('/');
54
+
if (pathParts.length < 2) {
55
+
return c.text('Invalid path format. Expected: /identifier/sitename/path', 400);
56
+
}
57
+
58
+
const identifier = pathParts[0];
59
+
const site = pathParts[1];
60
+
const filePath = pathParts.slice(2).join('/');
61
+
62
+
// Additional validation: identifier must be a valid DID or handle format
63
+
if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
64
+
return c.text('Invalid identifier', 400);
65
+
}
66
+
67
+
// Validate site parameter exists
68
+
if (!site) {
69
+
return c.text('Site name required', 400);
70
+
}
71
+
72
+
// Validate site name (rkey)
73
+
if (!isValidRkey(site)) {
74
+
return c.text('Invalid site name', 400);
75
+
}
76
+
77
+
// Resolve identifier to DID
78
+
const did = await resolveDid(identifier);
79
+
if (!did) {
80
+
return c.text('Invalid identifier', 400);
81
+
}
82
+
83
+
// Check if site is currently being cached - return updating response early
84
+
if (isSiteBeingCached(did, site)) {
85
+
return siteUpdatingResponse();
86
+
}
87
+
88
+
// Ensure site is cached
89
+
const cached = await ensureSiteCached(did, site);
90
+
if (!cached) {
91
+
return c.text('Site not found', 404);
92
+
}
93
+
94
+
// Serve with HTML path rewriting to handle absolute paths
95
+
const basePath = `/${identifier}/${site}/`;
96
+
const headers = extractHeaders(c.req.raw.headers);
97
+
return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers);
98
+
}
99
+
100
+
// Check if this is a DNS hash subdomain
101
+
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
102
+
if (dnsMatch) {
103
+
const hash = dnsMatch[1];
104
+
const baseDomain = dnsMatch[2];
105
+
106
+
if (!hash) {
107
+
return c.text('Invalid DNS hash', 400);
108
+
}
109
+
110
+
if (baseDomain !== BASE_HOST) {
111
+
return c.text('Invalid base domain', 400);
112
+
}
113
+
114
+
const customDomain = await getCustomDomainByHash(hash);
115
+
if (!customDomain) {
116
+
return c.text('Custom domain not found or not verified', 404);
117
+
}
118
+
119
+
if (!customDomain.rkey) {
120
+
return c.text('Domain not mapped to a site', 404);
121
+
}
122
+
123
+
const rkey = customDomain.rkey;
124
+
if (!isValidRkey(rkey)) {
125
+
return c.text('Invalid site configuration', 500);
126
+
}
127
+
128
+
// Check if site is currently being cached - return updating response early
129
+
if (isSiteBeingCached(customDomain.did, rkey)) {
130
+
return siteUpdatingResponse();
131
+
}
132
+
133
+
const cached = await ensureSiteCached(customDomain.did, rkey);
134
+
if (!cached) {
135
+
return c.text('Site not found', 404);
136
+
}
137
+
138
+
const headers = extractHeaders(c.req.raw.headers);
139
+
return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
140
+
}
141
+
142
+
// Route 2: Registered subdomains - /*.wisp.place/*
143
+
if (hostname.endsWith(`.${BASE_HOST}`)) {
144
+
const domainInfo = await getWispDomain(hostname);
145
+
if (!domainInfo) {
146
+
return c.text('Subdomain not registered', 404);
147
+
}
148
+
149
+
if (!domainInfo.rkey) {
150
+
return c.text('Domain not mapped to a site', 404);
151
+
}
152
+
153
+
const rkey = domainInfo.rkey;
154
+
if (!isValidRkey(rkey)) {
155
+
return c.text('Invalid site configuration', 500);
156
+
}
157
+
158
+
// Check if site is currently being cached - return updating response early
159
+
if (isSiteBeingCached(domainInfo.did, rkey)) {
160
+
return siteUpdatingResponse();
161
+
}
162
+
163
+
const cached = await ensureSiteCached(domainInfo.did, rkey);
164
+
if (!cached) {
165
+
return c.text('Site not found', 404);
166
+
}
167
+
168
+
const headers = extractHeaders(c.req.raw.headers);
169
+
return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers);
170
+
}
171
+
172
+
// Route 1: Custom domains - /*
173
+
const customDomain = await getCustomDomain(hostname);
174
+
if (!customDomain) {
175
+
return c.text('Custom domain not found or not verified', 404);
176
+
}
177
+
178
+
if (!customDomain.rkey) {
179
+
return c.text('Domain not mapped to a site', 404);
180
+
}
181
+
182
+
const rkey = customDomain.rkey;
183
+
if (!isValidRkey(rkey)) {
184
+
return c.text('Invalid site configuration', 500);
185
+
}
186
+
187
+
// Check if site is currently being cached - return updating response early
188
+
if (isSiteBeingCached(customDomain.did, rkey)) {
189
+
return siteUpdatingResponse();
190
+
}
191
+
192
+
const cached = await ensureSiteCached(customDomain.did, rkey);
193
+
if (!cached) {
194
+
return c.text('Site not found', 404);
195
+
}
196
+
197
+
const headers = extractHeaders(c.req.raw.headers);
198
+
return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
199
+
});
200
+
201
+
// Internal observability endpoints (for admin panel)
202
+
app.get('/__internal__/observability/logs', (c) => {
203
+
const query = c.req.query();
204
+
const filter: any = {};
205
+
if (query.level) filter.level = query.level;
206
+
if (query.service) filter.service = query.service;
207
+
if (query.search) filter.search = query.search;
208
+
if (query.eventType) filter.eventType = query.eventType;
209
+
if (query.limit) filter.limit = parseInt(query.limit as string);
210
+
return c.json({ logs: logCollector.getLogs(filter) });
211
+
});
212
+
213
+
app.get('/__internal__/observability/errors', (c) => {
214
+
const query = c.req.query();
215
+
const filter: any = {};
216
+
if (query.service) filter.service = query.service;
217
+
if (query.limit) filter.limit = parseInt(query.limit as string);
218
+
return c.json({ errors: errorTracker.getErrors(filter) });
219
+
});
220
+
221
+
app.get('/__internal__/observability/metrics', (c) => {
222
+
const query = c.req.query();
223
+
const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
224
+
const stats = metricsCollector.getStats('hosting-service', timeWindow);
225
+
return c.json({ stats, timeWindow });
226
+
});
227
+
228
+
app.get('/__internal__/observability/cache', async (c) => {
229
+
const { getCacheStats } = await import('./lib/cache');
230
+
const stats = getCacheStats();
231
+
return c.json({ cache: stats });
232
+
});
233
+
234
+
export default app;
+67
apps/main-app/package.json
+67
apps/main-app/package.json
···
1
+
{
2
+
"name": "@wisp/main-app",
3
+
"version": "1.0.50",
4
+
"private": true,
5
+
"scripts": {
6
+
"test": "bun test",
7
+
"dev": "bun run --watch src/index.ts",
8
+
"start": "bun run src/index.ts",
9
+
"build": "bun build --compile --target bun --outfile server src/index.ts",
10
+
"screenshot": "bun run scripts/screenshot-sites.ts"
11
+
},
12
+
"dependencies": {
13
+
"@wisp/lexicons": "workspace:*",
14
+
"@wisp/constants": "workspace:*",
15
+
"@wisp/observability": "workspace:*",
16
+
"@wisp/atproto-utils": "workspace:*",
17
+
"@wisp/database": "workspace:*",
18
+
"@wisp/fs-utils": "workspace:*",
19
+
"@atproto/api": "^0.17.3",
20
+
"@atproto/lex-cli": "^0.9.5",
21
+
"@atproto/oauth-client-node": "^0.3.9",
22
+
"@atproto/xrpc-server": "^0.9.5",
23
+
"@elysiajs/cors": "^1.4.0",
24
+
"@elysiajs/eden": "^1.4.3",
25
+
"@elysiajs/openapi": "^1.4.11",
26
+
"@elysiajs/opentelemetry": "^1.4.6",
27
+
"@elysiajs/static": "^1.4.2",
28
+
"@radix-ui/react-checkbox": "^1.3.3",
29
+
"@radix-ui/react-dialog": "^1.1.15",
30
+
"@radix-ui/react-label": "^2.1.7",
31
+
"@radix-ui/react-radio-group": "^1.3.8",
32
+
"@radix-ui/react-slot": "^1.2.3",
33
+
"@radix-ui/react-tabs": "^1.1.13",
34
+
"@tanstack/react-query": "^5.90.2",
35
+
"actor-typeahead": "^0.1.1",
36
+
"atproto-ui": "^0.11.3",
37
+
"class-variance-authority": "^0.7.1",
38
+
"clsx": "^2.1.1",
39
+
"elysia": "latest",
40
+
"iron-session": "^8.0.4",
41
+
"lucide-react": "^0.546.0",
42
+
"multiformats": "^13.4.1",
43
+
"prismjs": "^1.30.0",
44
+
"react": "^19.2.0",
45
+
"react-dom": "^19.2.0",
46
+
"tailwind-merge": "^3.3.1",
47
+
"tailwindcss": "4",
48
+
"tw-animate-css": "^1.4.0",
49
+
"typescript": "^5.9.3",
50
+
"zlib": "^1.0.5"
51
+
},
52
+
"devDependencies": {
53
+
"@types/react": "^19.2.2",
54
+
"@types/react-dom": "^19.2.1",
55
+
"bun-plugin-tailwind": "^0.1.2",
56
+
"bun-types": "latest",
57
+
"esbuild": "0.26.0",
58
+
"playwright": "^1.49.0"
59
+
},
60
+
"module": "src/index.js",
61
+
"trustedDependencies": [
62
+
"bun",
63
+
"cbor-extract",
64
+
"core-js",
65
+
"protobufjs"
66
+
]
67
+
}
+9
apps/main-app/src/lib/logger.ts
+9
apps/main-app/src/lib/logger.ts
···
1
+
/**
2
+
* Main app logger using @wisp/observability
3
+
*
4
+
* Note: This file is kept for backward compatibility.
5
+
* New code should import createLogger from @wisp/observability directly.
6
+
*/
7
+
import { createLogger } from '@wisp/observability'
8
+
9
+
export const logger = createLogger('main-app')
+14
apps/main-app/tsconfig.json
+14
apps/main-app/tsconfig.json
···
1
+
{
2
+
"extends": "../../tsconfig.json",
3
+
"compilerOptions": {
4
+
"baseUrl": ".",
5
+
"paths": {
6
+
"@server": ["./src/index.ts"],
7
+
"@server/*": ["./src/*"],
8
+
"@public/*": ["./public/*"],
9
+
"@wisp/*": ["../../packages/@wisp/*/src"]
10
+
}
11
+
},
12
+
"include": ["src/**/*", "public/**/*", "scripts/**/*"],
13
+
"exclude": ["node_modules"]
14
+
}
+291
-9
bun.lock
+291
-9
bun.lock
···
4
4
"workspaces": {
5
5
"": {
6
6
"name": "elysia-static",
7
+
},
8
+
"apps/hosting-service": {
9
+
"name": "wisp-hosting-service",
10
+
"version": "1.0.0",
11
+
"dependencies": {
12
+
"@atproto/api": "^0.17.4",
13
+
"@atproto/identity": "^0.4.9",
14
+
"@atproto/lexicon": "^0.5.1",
15
+
"@atproto/sync": "^0.1.36",
16
+
"@atproto/xrpc": "^0.7.5",
17
+
"@hono/node-server": "^1.19.6",
18
+
"@wisp/atproto-utils": "workspace:*",
19
+
"@wisp/constants": "workspace:*",
20
+
"@wisp/database": "workspace:*",
21
+
"@wisp/fs-utils": "workspace:*",
22
+
"@wisp/lexicons": "workspace:*",
23
+
"@wisp/observability": "workspace:*",
24
+
"@wisp/safe-fetch": "workspace:*",
25
+
"hono": "^4.10.4",
26
+
"mime-types": "^2.1.35",
27
+
"multiformats": "^13.4.1",
28
+
"postgres": "^3.4.5",
29
+
},
30
+
"devDependencies": {
31
+
"@types/bun": "^1.3.1",
32
+
"@types/mime-types": "^2.1.4",
33
+
"@types/node": "^22.10.5",
34
+
"tsx": "^4.19.2",
35
+
},
36
+
},
37
+
"apps/main-app": {
38
+
"name": "@wisp/main-app",
39
+
"version": "1.0.50",
7
40
"dependencies": {
8
41
"@atproto/api": "^0.17.3",
9
42
"@atproto/lex-cli": "^0.9.5",
···
21
54
"@radix-ui/react-slot": "^1.2.3",
22
55
"@radix-ui/react-tabs": "^1.1.13",
23
56
"@tanstack/react-query": "^5.90.2",
57
+
"@wisp/atproto-utils": "workspace:*",
58
+
"@wisp/constants": "workspace:*",
59
+
"@wisp/database": "workspace:*",
60
+
"@wisp/fs-utils": "workspace:*",
61
+
"@wisp/lexicons": "workspace:*",
62
+
"@wisp/observability": "workspace:*",
24
63
"actor-typeahead": "^0.1.1",
25
64
"atproto-ui": "^0.11.3",
26
65
"class-variance-authority": "^0.7.1",
···
47
86
"playwright": "^1.49.0",
48
87
},
49
88
},
89
+
"packages/@wisp/atproto-utils": {
90
+
"name": "@wisp/atproto-utils",
91
+
"version": "1.0.0",
92
+
"dependencies": {
93
+
"@atproto/api": "^0.14.1",
94
+
"@wisp/lexicons": "workspace:*",
95
+
"multiformats": "^13.3.1",
96
+
},
97
+
},
98
+
"packages/@wisp/constants": {
99
+
"name": "@wisp/constants",
100
+
"version": "1.0.0",
101
+
},
102
+
"packages/@wisp/database": {
103
+
"name": "@wisp/database",
104
+
"version": "1.0.0",
105
+
"dependencies": {
106
+
"postgres": "^3.4.5",
107
+
},
108
+
"peerDependencies": {
109
+
"bun": "^1.0.0",
110
+
},
111
+
"optionalPeers": [
112
+
"bun",
113
+
],
114
+
},
115
+
"packages/@wisp/fs-utils": {
116
+
"name": "@wisp/fs-utils",
117
+
"version": "1.0.0",
118
+
"dependencies": {
119
+
"@atproto/api": "^0.14.1",
120
+
"@wisp/lexicons": "workspace:*",
121
+
},
122
+
},
123
+
"packages/@wisp/lexicons": {
124
+
"name": "@wisp/lexicons",
125
+
"version": "1.0.0",
126
+
"dependencies": {
127
+
"@atproto/lexicon": "^0.5.1",
128
+
"@atproto/xrpc-server": "^0.9.5",
129
+
},
130
+
"devDependencies": {
131
+
"@atproto/lex-cli": "^0.9.5",
132
+
},
133
+
},
134
+
"packages/@wisp/observability": {
135
+
"name": "@wisp/observability",
136
+
"version": "1.0.0",
137
+
"peerDependencies": {
138
+
"hono": "^4.0.0",
139
+
},
140
+
"optionalPeers": [
141
+
"hono",
142
+
],
143
+
},
144
+
"packages/@wisp/safe-fetch": {
145
+
"name": "@wisp/safe-fetch",
146
+
"version": "1.0.0",
147
+
},
50
148
},
51
149
"trustedDependencies": [
52
150
"core-js",
···
89
187
90
188
"@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="],
91
189
92
-
"@atproto/api": ["@atproto/api@0.17.7", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw=="],
190
+
"@atproto/api": ["@atproto/api@0.14.22", "", { "dependencies": { "@atproto/common-web": "^0.4.1", "@atproto/lexicon": "^0.4.10", "@atproto/syntax": "^0.4.0", "@atproto/xrpc": "^0.6.12", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-ziXPau+sUdFovObSnsoN7JbOmUw1C5e5L28/yXf3P8vbEnSS3HVVGD1jYcscBYY34xQqi4bVDpwMYx/4yRsTuQ=="],
93
191
94
192
"@atproto/common": ["@atproto/common@0.4.12", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@ipld/dag-cbor": "^7.0.3", "cbor-x": "^1.5.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, "sha512-NC+TULLQiqs6MvNymhQS5WDms3SlbIKGLf4n33tpftRJcalh507rI+snbcUb7TLIkKw7VO17qMqxEXtIdd5auQ=="],
95
193
···
98
196
"@atproto/crypto": ["@atproto/crypto@0.4.4", "", { "dependencies": { "@noble/curves": "^1.7.0", "@noble/hashes": "^1.6.1", "uint8arrays": "3.0.0" } }, "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA=="],
99
197
100
198
"@atproto/did": ["@atproto/did@0.2.1", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-1i5BTU2GnBaaeYWhxUOnuEKFVq9euT5+dQPFabHpa927BlJ54PmLGyBBaOI7/NbLmN5HWwBa18SBkMpg3jGZRA=="],
199
+
200
+
"@atproto/identity": ["@atproto/identity@0.4.10", "", { "dependencies": { "@atproto/common-web": "^0.4.4", "@atproto/crypto": "^0.4.4" } }, "sha512-nQbzDLXOhM8p/wo0cTh5DfMSOSHzj6jizpodX37LJ4S1TZzumSxAjHEZa5Rev3JaoD5uSWMVE0MmKEGWkPPvfQ=="],
101
201
102
202
"@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="],
103
203
···
105
205
106
206
"@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="],
107
207
208
+
"@atproto/lex-cbor": ["@atproto/lex-cbor@0.0.1", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "multiformats": "^9.9.0", "tslib": "^2.8.1" } }, "sha512-GCgowcC041tYmsoIxalIECJq4ZRHgREk6lFa4BzNRUZarMqwz57YF/7eUlo2Q6hoaMUL7Bjr6FvXwcZFaKrhvA=="],
209
+
108
210
"@atproto/lex-cli": ["@atproto/lex-cli@0.9.6", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "chalk": "^4.1.2", "commander": "^9.4.0", "prettier": "^3.2.5", "ts-morph": "^24.0.0", "yesno": "^0.4.0", "zod": "^3.23.8" }, "bin": { "lex": "dist/index.js" } }, "sha512-EedEKmURoSP735YwSDHsFrLOhZ4P2it8goCHv5ApWi/R9DFpOKOpmYfIXJ9MAprK8cw+yBnjDJbzpLJy7UXlTg=="],
109
211
110
-
"@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
212
+
"@atproto/lex-data": ["@atproto/lex-data@0.0.1", "", { "dependencies": { "@atproto/syntax": "0.4.1", "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-DrS/8cQcQs3s5t9ELAFNtyDZ8/PdiCx47ALtFEP2GnX2uCBHZRkqWG7xmu6ehjc787nsFzZBvlnz3T/gov5fGA=="],
213
+
214
+
"@atproto/lex-json": ["@atproto/lex-json@0.0.1", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "tslib": "^2.8.1" } }, "sha512-ivcF7+pDRuD/P97IEKQ/9TruunXj0w58Khvwk3M6psaI5eZT6LRsRZ4cWcKaXiFX4SHnjy+x43g0f7pPtIsERg=="],
215
+
216
+
"@atproto/lexicon": ["@atproto/lexicon@0.5.2", "", { "dependencies": { "@atproto/common-web": "^0.4.4", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-lRmJgMA8f5j7VB5Iu5cp188ald5FuI4FlmZ7nn6EBrk1dgOstWVrI5Ft6K3z2vjyLZRG6nzknlsw+tDP63p7bQ=="],
111
217
112
218
"@atproto/oauth-client": ["@atproto/oauth-client@0.5.8", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.2", "@atproto-labs/identity-resolver": "0.3.2", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.5.0", "@atproto/xrpc": "0.7.5", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-7YEym6d97+Dd73qGdkQTXi5La8xvCQxwRUDzzlR/NVAARa9a4YP7MCmqBJVeP2anT0By+DSAPyPDLTsxcjIcCg=="],
113
219
···
115
221
116
222
"@atproto/oauth-types": ["@atproto/oauth-types@0.5.0", "", { "dependencies": { "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-33xz7HcXhbl+XRqbIMVu3GE02iK1nKe2oMWENASsfZEYbCz2b9ZOarOFuwi7g4LKqpGowGp0iRKsQHFcq4SDaQ=="],
117
223
224
+
"@atproto/repo": ["@atproto/repo@0.8.11", "", { "dependencies": { "@atproto/common": "^0.5.0", "@atproto/common-web": "^0.4.4", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.2", "@ipld/dag-cbor": "^7.0.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "varint": "^6.0.0", "zod": "^3.23.8" } }, "sha512-b/WCu5ITws4ILHoXiZz0XXB5U9C08fUVzkBQDwpnme62GXv8gUaAPL/ttG61OusW09ARwMMQm4vxoP0hTFg+zA=="],
225
+
226
+
"@atproto/sync": ["@atproto/sync@0.1.38", "", { "dependencies": { "@atproto/common": "^0.5.0", "@atproto/identity": "^0.4.10", "@atproto/lexicon": "^0.5.2", "@atproto/repo": "^0.8.11", "@atproto/syntax": "^0.4.1", "@atproto/xrpc-server": "^0.10.0", "multiformats": "^9.9.0", "p-queue": "^6.6.2", "ws": "^8.12.0" } }, "sha512-2rE0SM21Nk4hWw/XcIYFnzlWO6/gBg8mrzuWbOvDhD49sA/wW4zyjaHZ5t1gvk28/SLok2VZiIR8nYBdbf7F5Q=="],
227
+
118
228
"@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="],
229
+
230
+
"@atproto/ws-client": ["@atproto/ws-client@0.0.3", "", { "dependencies": { "@atproto/common": "^0.5.0", "ws": "^8.12.0" } }, "sha512-eKqkTWBk6zuMY+6gs02eT7mS8Btewm8/qaL/Dp00NDCqpNC+U59MWvQsOWT3xkNGfd9Eip+V6VI4oyPvAfsfTA=="],
119
231
120
232
"@atproto/xrpc": ["@atproto/xrpc@0.7.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "zod": "^3.23.8" } }, "sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA=="],
121
233
···
202
314
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.1", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ=="],
203
315
204
316
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
317
+
318
+
"@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="],
205
319
206
320
"@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA=="],
207
321
···
375
489
376
490
"@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="],
377
491
378
-
"@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
492
+
"@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
493
+
494
+
"@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="],
495
+
496
+
"@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="],
379
497
380
498
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
381
499
···
383
501
384
502
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
385
503
504
+
"@wisp/atproto-utils": ["@wisp/atproto-utils@workspace:packages/@wisp/atproto-utils"],
505
+
506
+
"@wisp/constants": ["@wisp/constants@workspace:packages/@wisp/constants"],
507
+
508
+
"@wisp/database": ["@wisp/database@workspace:packages/@wisp/database"],
509
+
510
+
"@wisp/fs-utils": ["@wisp/fs-utils@workspace:packages/@wisp/fs-utils"],
511
+
512
+
"@wisp/lexicons": ["@wisp/lexicons@workspace:packages/@wisp/lexicons"],
513
+
514
+
"@wisp/main-app": ["@wisp/main-app@workspace:apps/main-app"],
515
+
516
+
"@wisp/observability": ["@wisp/observability@workspace:packages/@wisp/observability"],
517
+
518
+
"@wisp/safe-fetch": ["@wisp/safe-fetch@workspace:packages/@wisp/safe-fetch"],
519
+
386
520
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
387
521
388
522
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
···
421
555
422
556
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
423
557
424
-
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
558
+
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
425
559
426
560
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
427
561
···
479
613
480
614
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
481
615
482
-
"elysia": ["elysia@1.4.15", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-RaDqqZdLuC4UJetfVRQ4Z5aVpGgEtQ+pZnsbI4ZzEaf3l/MzuHcqSVoL/Fue3d6qE4RV9HMB2rAZaHyPIxkyzg=="],
616
+
"elysia": ["elysia@1.4.16", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.3", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-KZtKN160/bdWVKg2hEgyoNXY8jRRquc+m6PboyisaLZL891I+Ufb7Ja6lDAD7vMQur8sLEWIcidZOzj5lWw9UA=="],
483
617
484
618
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
485
619
···
502
636
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
503
637
504
638
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
639
+
640
+
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
505
641
506
642
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
507
643
508
-
"exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="],
644
+
"exact-mirror": ["exact-mirror@0.2.3", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-aLdARfO0W0ntufjDyytUJQMbNXoB9g+BbA8KcgIq4XOOTYRw48yUGON/Pr64iDrYNZKcKvKbqE0MPW56FF2BXA=="],
509
645
510
646
"express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="],
511
647
···
536
672
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
537
673
538
674
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
675
+
676
+
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
539
677
540
678
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
541
679
···
547
685
548
686
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
549
687
688
+
"hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="],
689
+
550
690
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
551
691
552
692
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
···
557
697
558
698
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
559
699
560
-
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
700
+
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
561
701
562
702
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
563
703
···
614
754
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
615
755
616
756
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
757
+
758
+
"p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="],
759
+
760
+
"p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="],
761
+
762
+
"p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="],
617
763
618
764
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
619
765
···
635
781
636
782
"playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="],
637
783
784
+
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
785
+
638
786
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
639
787
640
788
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
···
676
824
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
677
825
678
826
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
827
+
828
+
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
679
829
680
830
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
681
831
···
737
887
738
888
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
739
889
890
+
"tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="],
891
+
740
892
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
741
893
742
894
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
···
751
903
752
904
"undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="],
753
905
754
-
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
906
+
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
907
+
908
+
"unicode-segmenter": ["unicode-segmenter@0.14.0", "", {}, "sha512-AH4lhPCJANUnSLEKnM4byboctePJzltF4xj8b+NbNiYeAkAXGh7px2K/4NANFp7dnr6+zB3e6HLu8Jj8SKyvYg=="],
755
909
756
910
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
757
911
···
761
915
762
916
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
763
917
918
+
"varint": ["varint@6.0.0", "", {}, "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="],
919
+
764
920
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
921
+
922
+
"wisp-hosting-service": ["wisp-hosting-service@workspace:apps/hosting-service"],
765
923
766
924
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
767
925
···
779
937
780
938
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
781
939
940
+
"@atproto-labs/fetch-node/ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
941
+
942
+
"@atproto/api/@atproto/lexicon": ["@atproto/lexicon@0.4.14", "", { "dependencies": { "@atproto/common-web": "^0.4.2", "@atproto/syntax": "^0.4.0", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ=="],
943
+
944
+
"@atproto/api/@atproto/xrpc": ["@atproto/xrpc@0.6.12", "", { "dependencies": { "@atproto/lexicon": "^0.4.10", "zod": "^3.23.8" } }, "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w=="],
945
+
782
946
"@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
783
947
784
948
"@atproto/common/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
785
949
786
950
"@atproto/common-web/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
787
951
952
+
"@atproto/identity/@atproto/common-web": ["@atproto/common-web@0.4.5", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "@atproto/lex-json": "0.0.1", "zod": "^3.23.8" } }, "sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA=="],
953
+
788
954
"@atproto/jwk/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
789
955
956
+
"@atproto/lex-cbor/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
957
+
958
+
"@atproto/lex-cli/@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
959
+
960
+
"@atproto/lex-data/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
961
+
962
+
"@atproto/lexicon/@atproto/common-web": ["@atproto/common-web@0.4.5", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "@atproto/lex-json": "0.0.1", "zod": "^3.23.8" } }, "sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA=="],
963
+
790
964
"@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
791
965
792
966
"@atproto/oauth-client/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
793
967
968
+
"@atproto/repo/@atproto/common": ["@atproto/common@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.5", "@atproto/lex-cbor": "0.0.1", "@atproto/lex-data": "0.0.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, "sha512-0S57sjzw4r9OLc5srJFi6uAz/aTKYl6btz3x36tSnGriL716m6h0x2IVtgd+FhUfIQfisevrqcqw8SfaGk8VTw=="],
969
+
970
+
"@atproto/repo/@atproto/common-web": ["@atproto/common-web@0.4.5", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "@atproto/lex-json": "0.0.1", "zod": "^3.23.8" } }, "sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA=="],
971
+
972
+
"@atproto/repo/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
973
+
974
+
"@atproto/sync/@atproto/common": ["@atproto/common@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.5", "@atproto/lex-cbor": "0.0.1", "@atproto/lex-data": "0.0.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, "sha512-0S57sjzw4r9OLc5srJFi6uAz/aTKYl6btz3x36tSnGriL716m6h0x2IVtgd+FhUfIQfisevrqcqw8SfaGk8VTw=="],
975
+
976
+
"@atproto/sync/@atproto/xrpc-server": ["@atproto/xrpc-server@0.10.1", "", { "dependencies": { "@atproto/common": "^0.5.1", "@atproto/crypto": "^0.4.4", "@atproto/lex-cbor": "0.0.1", "@atproto/lex-data": "0.0.1", "@atproto/lexicon": "^0.5.2", "@atproto/ws-client": "^0.0.3", "@atproto/xrpc": "^0.7.6", "express": "^4.17.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "rate-limiter-flexible": "^2.4.1", "ws": "^8.12.0", "zod": "^3.23.8" } }, "sha512-kHXykL4inBV/49vefn5zR5zv/VM1//+BIRqk9OvB3+mbERw0jkFiHhc6PWyY/81VD4ciu7FZwUCpRy/mtQtIaA=="],
977
+
978
+
"@atproto/sync/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
979
+
980
+
"@atproto/ws-client/@atproto/common": ["@atproto/common@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.5", "@atproto/lex-cbor": "0.0.1", "@atproto/lex-data": "0.0.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, "sha512-0S57sjzw4r9OLc5srJFi6uAz/aTKYl6btz3x36tSnGriL716m6h0x2IVtgd+FhUfIQfisevrqcqw8SfaGk8VTw=="],
981
+
982
+
"@atproto/xrpc/@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
983
+
984
+
"@atproto/xrpc-server/@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
985
+
794
986
"@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
795
987
796
988
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
···
802
994
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
803
995
804
996
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
997
+
998
+
"@wisp/main-app/@atproto/api": ["@atproto/api@0.17.7", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw=="],
999
+
1000
+
"bun-types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
805
1001
806
1002
"express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],
807
1003
808
1004
"iron-session/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
809
1005
810
-
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
1006
+
"protobufjs/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
811
1007
812
1008
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
813
1009
···
815
1011
816
1012
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
817
1013
1014
+
"tsx/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
1015
+
1016
+
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
1017
+
818
1018
"uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
819
1019
1020
+
"wisp-hosting-service/@atproto/api": ["@atproto/api@0.17.7", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw=="],
1021
+
1022
+
"wisp-hosting-service/@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
1023
+
1024
+
"@atproto/lex-cli/@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
1025
+
1026
+
"@atproto/sync/@atproto/common/@atproto/common-web": ["@atproto/common-web@0.4.5", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "@atproto/lex-json": "0.0.1", "zod": "^3.23.8" } }, "sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA=="],
1027
+
1028
+
"@atproto/sync/@atproto/xrpc-server/@atproto/xrpc": ["@atproto/xrpc@0.7.6", "", { "dependencies": { "@atproto/lexicon": "^0.5.2", "zod": "^3.23.8" } }, "sha512-RvCf4j0JnKYWuz3QzsYCntJi3VuiAAybQsMIUw2wLWcHhchO9F7UaBZINLL2z0qc/cYWPv5NSwcVydMseoCZLA=="],
1029
+
1030
+
"@atproto/ws-client/@atproto/common/@atproto/common-web": ["@atproto/common-web@0.4.5", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "@atproto/lex-json": "0.0.1", "zod": "^3.23.8" } }, "sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA=="],
1031
+
1032
+
"@atproto/ws-client/@atproto/common/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
1033
+
1034
+
"@atproto/xrpc-server/@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
1035
+
1036
+
"@atproto/xrpc/@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
1037
+
820
1038
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
1039
+
1040
+
"@wisp/main-app/@atproto/api/@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
1041
+
1042
+
"@wisp/main-app/@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
1043
+
1044
+
"bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
1045
+
1046
+
"protobufjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
821
1047
822
1048
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
1049
+
1050
+
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
1051
+
1052
+
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
1053
+
1054
+
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
1055
+
1056
+
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
1057
+
1058
+
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
1059
+
1060
+
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
1061
+
1062
+
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
1063
+
1064
+
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
1065
+
1066
+
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
1067
+
1068
+
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
1069
+
1070
+
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
1071
+
1072
+
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
1073
+
1074
+
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
1075
+
1076
+
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
1077
+
1078
+
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
1079
+
1080
+
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
1081
+
1082
+
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
1083
+
1084
+
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
1085
+
1086
+
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
1087
+
1088
+
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
1089
+
1090
+
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
1091
+
1092
+
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
1093
+
1094
+
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
1095
+
1096
+
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
1097
+
1098
+
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
1099
+
1100
+
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
1101
+
1102
+
"wisp-hosting-service/@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
1103
+
1104
+
"wisp-hosting-service/@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
823
1105
}
824
1106
}
hosting-service/.dockerignore
apps/hosting-service/.dockerignore
hosting-service/.dockerignore
apps/hosting-service/.dockerignore
hosting-service/.env.example
apps/hosting-service/.env.example
hosting-service/.env.example
apps/hosting-service/.env.example
hosting-service/.gitignore
apps/hosting-service/.gitignore
hosting-service/.gitignore
apps/hosting-service/.gitignore
hosting-service/Dockerfile
apps/hosting-service/Dockerfile
hosting-service/Dockerfile
apps/hosting-service/Dockerfile
hosting-service/README.md
apps/hosting-service/README.md
hosting-service/README.md
apps/hosting-service/README.md
hosting-service/bun.lock
apps/hosting-service/bun.lock
hosting-service/bun.lock
apps/hosting-service/bun.lock
-51
hosting-service/debug-settings.ts
-51
hosting-service/debug-settings.ts
···
1
-
#!/usr/bin/env tsx
2
-
/**
3
-
* Debug script to check cached settings for a site
4
-
* Usage: tsx debug-settings.ts <did> <rkey>
5
-
*/
6
-
7
-
import { readFile } from 'fs/promises';
8
-
import { existsSync } from 'fs';
9
-
10
-
const CACHE_DIR = './cache';
11
-
12
-
async function debugSettings(did: string, rkey: string) {
13
-
const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`;
14
-
15
-
console.log('Checking metadata at:', metadataPath);
16
-
console.log('Exists:', existsSync(metadataPath));
17
-
18
-
if (!existsSync(metadataPath)) {
19
-
console.log('\n❌ Metadata file does not exist - site may not be cached yet');
20
-
return;
21
-
}
22
-
23
-
const content = await readFile(metadataPath, 'utf-8');
24
-
const metadata = JSON.parse(content);
25
-
26
-
console.log('\n=== Cached Metadata ===');
27
-
console.log('CID:', metadata.cid);
28
-
console.log('Cached at:', metadata.cachedAt);
29
-
console.log('\n=== Settings ===');
30
-
if (metadata.settings) {
31
-
console.log(JSON.stringify(metadata.settings, null, 2));
32
-
} else {
33
-
console.log('❌ No settings found in metadata');
34
-
console.log('This means:');
35
-
console.log(' 1. No place.wisp.settings record exists on the PDS');
36
-
console.log(' 2. Or the firehose hasn\'t picked up the settings yet');
37
-
console.log('\nTo fix:');
38
-
console.log(' 1. Create a place.wisp.settings record with the same rkey');
39
-
console.log(' 2. Wait for firehose to pick it up (a few seconds)');
40
-
console.log(' 3. Or manually re-cache the site');
41
-
}
42
-
}
43
-
44
-
const [did, rkey] = process.argv.slice(2);
45
-
if (!did || !rkey) {
46
-
console.log('Usage: tsx debug-settings.ts <did> <rkey>');
47
-
console.log('Example: tsx debug-settings.ts did:plc:abc123 my-site');
48
-
process.exit(1);
49
-
}
50
-
51
-
debugSettings(did, rkey).catch(console.error);
hosting-service/docker-entrypoint.sh
apps/hosting-service/docker-entrypoint.sh
hosting-service/docker-entrypoint.sh
apps/hosting-service/docker-entrypoint.sh
-134
hosting-service/example-_redirects
-134
hosting-service/example-_redirects
···
1
-
# Example _redirects file for Wisp hosting
2
-
# Place this file in the root directory of your site as "_redirects"
3
-
# Lines starting with # are comments
4
-
5
-
# ===================================
6
-
# SIMPLE REDIRECTS
7
-
# ===================================
8
-
9
-
# Redirect home page
10
-
# /home /
11
-
12
-
# Redirect old URLs to new ones
13
-
# /old-blog /blog
14
-
# /about-us /about
15
-
16
-
# ===================================
17
-
# SPLAT REDIRECTS (WILDCARDS)
18
-
# ===================================
19
-
20
-
# Redirect entire directories
21
-
# /news/* /blog/:splat
22
-
# /old-site/* /new-site/:splat
23
-
24
-
# ===================================
25
-
# PLACEHOLDER REDIRECTS
26
-
# ===================================
27
-
28
-
# Restructure blog URLs
29
-
# /blog/:year/:month/:day/:slug /posts/:year-:month-:day/:slug
30
-
31
-
# Capture multiple parameters
32
-
# /products/:category/:id /shop/:category/item/:id
33
-
34
-
# ===================================
35
-
# STATUS CODES
36
-
# ===================================
37
-
38
-
# Permanent redirect (301) - default if not specified
39
-
# /permanent-move /new-location 301
40
-
41
-
# Temporary redirect (302)
42
-
# /temp-redirect /temp-location 302
43
-
44
-
# Rewrite (200) - serves different content, URL stays the same
45
-
# /api/* /functions/:splat 200
46
-
47
-
# Custom 404 page
48
-
# /shop/* /shop-closed.html 404
49
-
50
-
# ===================================
51
-
# FORCE REDIRECTS
52
-
# ===================================
53
-
54
-
# Force redirect even if file exists (note the ! after status code)
55
-
# /override-file /other-file.html 200!
56
-
57
-
# ===================================
58
-
# CONDITIONAL REDIRECTS
59
-
# ===================================
60
-
61
-
# Country-based redirects (ISO 3166-1 alpha-2 codes)
62
-
# / /us/ 302 Country=us
63
-
# / /uk/ 302 Country=gb
64
-
# / /anz/ 302 Country=au,nz
65
-
66
-
# Language-based redirects
67
-
# /products /en/products 301 Language=en
68
-
# /products /de/products 301 Language=de
69
-
# /products /fr/products 301 Language=fr
70
-
71
-
# Cookie-based redirects (checks if cookie exists)
72
-
# /* /legacy/:splat 200 Cookie=is_legacy
73
-
74
-
# ===================================
75
-
# QUERY PARAMETERS
76
-
# ===================================
77
-
78
-
# Match specific query parameters
79
-
# /store id=:id /blog/:id 301
80
-
81
-
# Multiple parameters
82
-
# /search q=:query category=:cat /find/:cat/:query 301
83
-
84
-
# ===================================
85
-
# DOMAIN-LEVEL REDIRECTS
86
-
# ===================================
87
-
88
-
# Redirect to different domain (must include protocol)
89
-
# /external https://example.com/path
90
-
91
-
# Redirect entire subdomain
92
-
# http://blog.example.com/* https://example.com/blog/:splat 301!
93
-
# https://blog.example.com/* https://example.com/blog/:splat 301!
94
-
95
-
# ===================================
96
-
# COMMON PATTERNS
97
-
# ===================================
98
-
99
-
# Remove .html extensions
100
-
# /page.html /page
101
-
102
-
# Add trailing slash
103
-
# /about /about/
104
-
105
-
# Single-page app fallback (serve index.html for all paths)
106
-
# /* /index.html 200
107
-
108
-
# API proxy
109
-
# /api/* https://api.example.com/:splat 200
110
-
111
-
# ===================================
112
-
# CUSTOM ERROR PAGES
113
-
# ===================================
114
-
115
-
# Language-specific 404 pages
116
-
# /en/* /en/404.html 404
117
-
# /de/* /de/404.html 404
118
-
119
-
# Section-specific 404 pages
120
-
# /shop/* /shop/not-found.html 404
121
-
# /blog/* /blog/404.html 404
122
-
123
-
# ===================================
124
-
# NOTES
125
-
# ===================================
126
-
#
127
-
# - Rules are processed in order (first match wins)
128
-
# - More specific rules should come before general ones
129
-
# - Splats (*) can only be used at the end of a path
130
-
# - Query parameters are automatically preserved for 200, 301, 302
131
-
# - Trailing slashes are normalized (/ and no / are treated the same)
132
-
# - Default status code is 301 if not specified
133
-
#
134
-
+7
hosting-service/package.json
apps/hosting-service/package.json
+7
hosting-service/package.json
apps/hosting-service/package.json
···
9
9
"backfill": "tsx src/index.ts --backfill"
10
10
},
11
11
"dependencies": {
12
+
"@wisp/lexicons": "workspace:*",
13
+
"@wisp/constants": "workspace:*",
14
+
"@wisp/observability": "workspace:*",
15
+
"@wisp/atproto-utils": "workspace:*",
16
+
"@wisp/database": "workspace:*",
17
+
"@wisp/fs-utils": "workspace:*",
18
+
"@wisp/safe-fetch": "workspace:*",
12
19
"@atproto/api": "^0.17.4",
13
20
"@atproto/identity": "^0.4.9",
14
21
"@atproto/lexicon": "^0.5.1",
+3
-1
hosting-service/src/index.ts
apps/hosting-service/src/index.ts
+3
-1
hosting-service/src/index.ts
apps/hosting-service/src/index.ts
···
1
1
import app from './server';
2
2
import { serve } from '@hono/node-server';
3
3
import { FirehoseWorker } from './lib/firehose';
4
-
import { logger } from './lib/observability';
4
+
import { createLogger } from '@wisp/observability';
5
5
import { mkdirSync, existsSync } from 'fs';
6
6
import { backfillCache } from './lib/backfill';
7
7
import { startDomainCacheCleanup, stopDomainCacheCleanup, setCacheOnlyMode } from './lib/db';
8
+
9
+
const logger = createLogger('hosting-service');
8
10
9
11
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
10
12
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
-44
hosting-service/src/lexicon/index.ts
-44
hosting-service/src/lexicon/index.ts
···
1
-
/**
2
-
* GENERATED CODE - DO NOT MODIFY
3
-
*/
4
-
import {
5
-
type Auth,
6
-
type Options as XrpcOptions,
7
-
Server as XrpcServer,
8
-
type StreamConfigOrHandler,
9
-
type MethodConfigOrHandler,
10
-
createServer as createXrpcServer,
11
-
} from '@atproto/xrpc-server'
12
-
import { schemas } from './lexicons.js'
13
-
14
-
export function createServer(options?: XrpcOptions): Server {
15
-
return new Server(options)
16
-
}
17
-
18
-
export class Server {
19
-
xrpc: XrpcServer
20
-
place: PlaceNS
21
-
22
-
constructor(options?: XrpcOptions) {
23
-
this.xrpc = createXrpcServer(schemas, options)
24
-
this.place = new PlaceNS(this)
25
-
}
26
-
}
27
-
28
-
export class PlaceNS {
29
-
_server: Server
30
-
wisp: PlaceWispNS
31
-
32
-
constructor(server: Server) {
33
-
this._server = server
34
-
this.wisp = new PlaceWispNS(server)
35
-
}
36
-
}
37
-
38
-
export class PlaceWispNS {
39
-
_server: Server
40
-
41
-
constructor(server: Server) {
42
-
this._server = server
43
-
}
44
-
}
-364
hosting-service/src/lexicon/lexicons.ts
-364
hosting-service/src/lexicon/lexicons.ts
···
1
-
/**
2
-
* GENERATED CODE - DO NOT MODIFY
3
-
*/
4
-
import {
5
-
type LexiconDoc,
6
-
Lexicons,
7
-
ValidationError,
8
-
type ValidationResult,
9
-
} from '@atproto/lexicon'
10
-
import { type $Typed, is$typed, maybe$typed } from './util.js'
11
-
12
-
export const schemaDict = {
13
-
PlaceWispFs: {
14
-
lexicon: 1,
15
-
id: 'place.wisp.fs',
16
-
defs: {
17
-
main: {
18
-
type: 'record',
19
-
description: 'Virtual filesystem manifest for a Wisp site',
20
-
record: {
21
-
type: 'object',
22
-
required: ['site', 'root', 'createdAt'],
23
-
properties: {
24
-
site: {
25
-
type: 'string',
26
-
},
27
-
root: {
28
-
type: 'ref',
29
-
ref: 'lex:place.wisp.fs#directory',
30
-
},
31
-
fileCount: {
32
-
type: 'integer',
33
-
minimum: 0,
34
-
maximum: 1000,
35
-
},
36
-
createdAt: {
37
-
type: 'string',
38
-
format: 'datetime',
39
-
},
40
-
},
41
-
},
42
-
},
43
-
file: {
44
-
type: 'object',
45
-
required: ['type', 'blob'],
46
-
properties: {
47
-
type: {
48
-
type: 'string',
49
-
const: 'file',
50
-
},
51
-
blob: {
52
-
type: 'blob',
53
-
accept: ['*/*'],
54
-
maxSize: 1000000000,
55
-
description: 'Content blob ref',
56
-
},
57
-
encoding: {
58
-
type: 'string',
59
-
enum: ['gzip'],
60
-
description: 'Content encoding (e.g., gzip for compressed files)',
61
-
},
62
-
mimeType: {
63
-
type: 'string',
64
-
description: 'Original MIME type before compression',
65
-
},
66
-
base64: {
67
-
type: 'boolean',
68
-
description:
69
-
'True if blob content is base64-encoded (used to bypass PDS content sniffing)',
70
-
},
71
-
},
72
-
},
73
-
directory: {
74
-
type: 'object',
75
-
required: ['type', 'entries'],
76
-
properties: {
77
-
type: {
78
-
type: 'string',
79
-
const: 'directory',
80
-
},
81
-
entries: {
82
-
type: 'array',
83
-
maxLength: 500,
84
-
items: {
85
-
type: 'ref',
86
-
ref: 'lex:place.wisp.fs#entry',
87
-
},
88
-
},
89
-
},
90
-
},
91
-
entry: {
92
-
type: 'object',
93
-
required: ['name', 'node'],
94
-
properties: {
95
-
name: {
96
-
type: 'string',
97
-
maxLength: 255,
98
-
},
99
-
node: {
100
-
type: 'union',
101
-
refs: [
102
-
'lex:place.wisp.fs#file',
103
-
'lex:place.wisp.fs#directory',
104
-
'lex:place.wisp.fs#subfs',
105
-
],
106
-
},
107
-
},
108
-
},
109
-
subfs: {
110
-
type: 'object',
111
-
required: ['type', 'subject'],
112
-
properties: {
113
-
type: {
114
-
type: 'string',
115
-
const: 'subfs',
116
-
},
117
-
subject: {
118
-
type: 'string',
119
-
format: 'at-uri',
120
-
description:
121
-
'AT-URI pointing to a place.wisp.subfs record containing this subtree.',
122
-
},
123
-
flat: {
124
-
type: 'boolean',
125
-
description:
126
-
"If true (default), the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false, the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure.",
127
-
},
128
-
},
129
-
},
130
-
},
131
-
},
132
-
PlaceWispSettings: {
133
-
lexicon: 1,
134
-
id: 'place.wisp.settings',
135
-
defs: {
136
-
main: {
137
-
type: 'record',
138
-
description:
139
-
'Configuration settings for a static site hosted on wisp.place',
140
-
key: 'any',
141
-
record: {
142
-
type: 'object',
143
-
properties: {
144
-
directoryListing: {
145
-
type: 'boolean',
146
-
description:
147
-
'Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode.',
148
-
default: false,
149
-
},
150
-
spaMode: {
151
-
type: 'string',
152
-
description:
153
-
"File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404.",
154
-
maxLength: 500,
155
-
},
156
-
custom404: {
157
-
type: 'string',
158
-
description:
159
-
'Custom 404 error page file path. Incompatible with directoryListing and spaMode.',
160
-
maxLength: 500,
161
-
},
162
-
indexFiles: {
163
-
type: 'array',
164
-
description:
165
-
"Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified.",
166
-
items: {
167
-
type: 'string',
168
-
maxLength: 255,
169
-
},
170
-
maxLength: 10,
171
-
},
172
-
cleanUrls: {
173
-
type: 'boolean',
174
-
description:
175
-
"Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically.",
176
-
default: false,
177
-
},
178
-
headers: {
179
-
type: 'array',
180
-
description: 'Custom HTTP headers to set on responses',
181
-
items: {
182
-
type: 'ref',
183
-
ref: 'lex:place.wisp.settings#customHeader',
184
-
},
185
-
maxLength: 50,
186
-
},
187
-
},
188
-
},
189
-
},
190
-
customHeader: {
191
-
type: 'object',
192
-
description: 'Custom HTTP header configuration',
193
-
required: ['name', 'value'],
194
-
properties: {
195
-
name: {
196
-
type: 'string',
197
-
description:
198
-
"HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options')",
199
-
maxLength: 100,
200
-
},
201
-
value: {
202
-
type: 'string',
203
-
description: 'HTTP header value',
204
-
maxLength: 1000,
205
-
},
206
-
path: {
207
-
type: 'string',
208
-
description:
209
-
"Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths.",
210
-
maxLength: 500,
211
-
},
212
-
},
213
-
},
214
-
},
215
-
},
216
-
PlaceWispSubfs: {
217
-
lexicon: 1,
218
-
id: 'place.wisp.subfs',
219
-
defs: {
220
-
main: {
221
-
type: 'record',
222
-
description:
223
-
'Virtual filesystem subtree referenced by place.wisp.fs records. When a subfs entry is expanded, its root entries are merged (flattened) into the parent directory, allowing large directories to be split across multiple records while maintaining a flat structure.',
224
-
record: {
225
-
type: 'object',
226
-
required: ['root', 'createdAt'],
227
-
properties: {
228
-
root: {
229
-
type: 'ref',
230
-
ref: 'lex:place.wisp.subfs#directory',
231
-
},
232
-
fileCount: {
233
-
type: 'integer',
234
-
minimum: 0,
235
-
maximum: 1000,
236
-
},
237
-
createdAt: {
238
-
type: 'string',
239
-
format: 'datetime',
240
-
},
241
-
},
242
-
},
243
-
},
244
-
file: {
245
-
type: 'object',
246
-
required: ['type', 'blob'],
247
-
properties: {
248
-
type: {
249
-
type: 'string',
250
-
const: 'file',
251
-
},
252
-
blob: {
253
-
type: 'blob',
254
-
accept: ['*/*'],
255
-
maxSize: 1000000000,
256
-
description: 'Content blob ref',
257
-
},
258
-
encoding: {
259
-
type: 'string',
260
-
enum: ['gzip'],
261
-
description: 'Content encoding (e.g., gzip for compressed files)',
262
-
},
263
-
mimeType: {
264
-
type: 'string',
265
-
description: 'Original MIME type before compression',
266
-
},
267
-
base64: {
268
-
type: 'boolean',
269
-
description:
270
-
'True if blob content is base64-encoded (used to bypass PDS content sniffing)',
271
-
},
272
-
},
273
-
},
274
-
directory: {
275
-
type: 'object',
276
-
required: ['type', 'entries'],
277
-
properties: {
278
-
type: {
279
-
type: 'string',
280
-
const: 'directory',
281
-
},
282
-
entries: {
283
-
type: 'array',
284
-
maxLength: 500,
285
-
items: {
286
-
type: 'ref',
287
-
ref: 'lex:place.wisp.subfs#entry',
288
-
},
289
-
},
290
-
},
291
-
},
292
-
entry: {
293
-
type: 'object',
294
-
required: ['name', 'node'],
295
-
properties: {
296
-
name: {
297
-
type: 'string',
298
-
maxLength: 255,
299
-
},
300
-
node: {
301
-
type: 'union',
302
-
refs: [
303
-
'lex:place.wisp.subfs#file',
304
-
'lex:place.wisp.subfs#directory',
305
-
'lex:place.wisp.subfs#subfs',
306
-
],
307
-
},
308
-
},
309
-
},
310
-
subfs: {
311
-
type: 'object',
312
-
required: ['type', 'subject'],
313
-
properties: {
314
-
type: {
315
-
type: 'string',
316
-
const: 'subfs',
317
-
},
318
-
subject: {
319
-
type: 'string',
320
-
format: 'at-uri',
321
-
description:
322
-
"AT-URI pointing to another place.wisp.subfs record for nested subtrees. When expanded, the referenced record's root entries are merged (flattened) into the parent directory, allowing recursive splitting of large directory structures.",
323
-
},
324
-
},
325
-
},
326
-
},
327
-
},
328
-
} as const satisfies Record<string, LexiconDoc>
329
-
export const schemas = Object.values(schemaDict) satisfies LexiconDoc[]
330
-
export const lexicons: Lexicons = new Lexicons(schemas)
331
-
332
-
export function validate<T extends { $type: string }>(
333
-
v: unknown,
334
-
id: string,
335
-
hash: string,
336
-
requiredType: true,
337
-
): ValidationResult<T>
338
-
export function validate<T extends { $type?: string }>(
339
-
v: unknown,
340
-
id: string,
341
-
hash: string,
342
-
requiredType?: false,
343
-
): ValidationResult<T>
344
-
export function validate(
345
-
v: unknown,
346
-
id: string,
347
-
hash: string,
348
-
requiredType?: boolean,
349
-
): ValidationResult {
350
-
return (requiredType ? is$typed : maybe$typed)(v, id, hash)
351
-
? lexicons.validate(`${id}#${hash}`, v)
352
-
: {
353
-
success: false,
354
-
error: new ValidationError(
355
-
`Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`,
356
-
),
357
-
}
358
-
}
359
-
360
-
export const ids = {
361
-
PlaceWispFs: 'place.wisp.fs',
362
-
PlaceWispSettings: 'place.wisp.settings',
363
-
PlaceWispSubfs: 'place.wisp.subfs',
364
-
} as const
hosting-service/src/lexicon/types/place/wisp/fs.ts
packages/@wisp/lexicons/src/types/place/wisp/fs.ts
hosting-service/src/lexicon/types/place/wisp/fs.ts
packages/@wisp/lexicons/src/types/place/wisp/fs.ts
hosting-service/src/lexicon/types/place/wisp/settings.ts
packages/@wisp/lexicons/src/types/place/wisp/settings.ts
hosting-service/src/lexicon/types/place/wisp/settings.ts
packages/@wisp/lexicons/src/types/place/wisp/settings.ts
hosting-service/src/lexicon/types/place/wisp/subfs.ts
packages/@wisp/lexicons/src/types/place/wisp/subfs.ts
hosting-service/src/lexicon/types/place/wisp/subfs.ts
packages/@wisp/lexicons/src/types/place/wisp/subfs.ts
hosting-service/src/lexicon/util.ts
packages/@wisp/lexicons/src/util.ts
hosting-service/src/lexicon/util.ts
packages/@wisp/lexicons/src/util.ts
+4
-2
hosting-service/src/lib/backfill.ts
apps/hosting-service/src/lib/backfill.ts
+4
-2
hosting-service/src/lib/backfill.ts
apps/hosting-service/src/lib/backfill.ts
···
1
1
import { getAllSites } from './db';
2
2
import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils';
3
-
import { logger } from './observability';
3
+
import { createLogger } from '@wisp/observability';
4
4
import { markSiteAsBeingCached, unmarkSiteAsBeingCached } from './cache';
5
-
import { clearRedirectRulesCache } from '../server';
5
+
import { clearRedirectRulesCache } from './site-cache';
6
+
7
+
const logger = createLogger('hosting-service');
6
8
7
9
export interface BackfillOptions {
8
10
skipExisting?: boolean; // Skip sites already in cache
hosting-service/src/lib/cache.ts
apps/hosting-service/src/lib/cache.ts
hosting-service/src/lib/cache.ts
apps/hosting-service/src/lib/cache.ts
+1
-15
hosting-service/src/lib/db.ts
apps/hosting-service/src/lib/db.ts
+1
-15
hosting-service/src/lib/db.ts
apps/hosting-service/src/lib/db.ts
···
1
1
import postgres from 'postgres';
2
2
import { createHash } from 'crypto';
3
+
import type { DomainLookup, CustomDomainLookup } from '@wisp/database';
3
4
4
5
// Global cache-only mode flag (set by index.ts)
5
6
let cacheOnlyMode = false;
···
58
59
cleanupInterval = null;
59
60
}
60
61
}
61
-
62
-
export interface DomainLookup {
63
-
did: string;
64
-
rkey: string | null;
65
-
}
66
-
67
-
export interface CustomDomainLookup {
68
-
id: string;
69
-
domain: string;
70
-
did: string;
71
-
rkey: string | null;
72
-
verified: boolean;
73
-
}
74
-
75
-
76
62
77
63
export async function getWispDomain(domain: string): Promise<DomainLookup | null> {
78
64
const key = domain.toLowerCase();
+3
-4
hosting-service/src/lib/firehose.ts
apps/hosting-service/src/lib/firehose.ts
+3
-4
hosting-service/src/lib/firehose.ts
apps/hosting-service/src/lib/firehose.ts
···
2
2
import {
3
3
getPdsForDid,
4
4
downloadAndCacheSite,
5
-
extractBlobCid,
6
5
fetchSiteRecord
7
6
} from './utils'
8
7
import { upsertSite, tryAcquireLock, releaseLock } from './db'
9
-
import { safeFetch } from './safe-fetch'
10
-
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'
8
+
import { safeFetch } from '@wisp/safe-fetch'
9
+
import { isRecord, validateRecord } from '@wisp/lexicons/types/place/wisp/fs'
11
10
import { Firehose } from '@atproto/sync'
12
11
import { IdResolver } from '@atproto/identity'
13
12
import { invalidateSiteCache, markSiteAsBeingCached, unmarkSiteAsBeingCached } from './cache'
14
-
import { clearRedirectRulesCache } from '../server'
13
+
import { clearRedirectRulesCache } from './site-cache'
15
14
16
15
const CACHE_DIR = './cache/sites'
17
16
hosting-service/src/lib/html-rewriter.test.ts
apps/hosting-service/src/lib/html-rewriter.test.ts
hosting-service/src/lib/html-rewriter.test.ts
apps/hosting-service/src/lib/html-rewriter.test.ts
hosting-service/src/lib/html-rewriter.ts
apps/hosting-service/src/lib/html-rewriter.ts
hosting-service/src/lib/html-rewriter.ts
apps/hosting-service/src/lib/html-rewriter.ts
+103
-61
hosting-service/src/lib/observability.ts
packages/@wisp/observability/src/core.ts
+103
-61
hosting-service/src/lib/observability.ts
packages/@wisp/observability/src/core.ts
···
1
-
// DIY Observability for Hosting Service
2
-
import type { Context } from 'hono'
1
+
/**
2
+
* Core observability types and collectors
3
+
* Framework-agnostic logging, error tracking, and metrics collection
4
+
*/
3
5
6
+
// ============================================================================
4
7
// Types
8
+
// ============================================================================
9
+
5
10
export interface LogEntry {
6
11
id: string
7
12
timestamp: Date
···
33
38
service: string
34
39
}
35
40
36
-
// In-memory storage with rotation
41
+
export interface LogFilter {
42
+
level?: string
43
+
service?: string
44
+
limit?: number
45
+
search?: string
46
+
eventType?: string
47
+
}
48
+
49
+
export interface ErrorFilter {
50
+
service?: string
51
+
limit?: number
52
+
}
53
+
54
+
export interface MetricFilter {
55
+
service?: string
56
+
timeWindow?: number
57
+
}
58
+
59
+
export interface MetricStats {
60
+
totalRequests: number
61
+
avgDuration: number
62
+
p50Duration: number
63
+
p95Duration: number
64
+
p99Duration: number
65
+
errorRate: number
66
+
requestsPerMinute: number
67
+
}
68
+
69
+
// ============================================================================
70
+
// Configuration
71
+
// ============================================================================
72
+
37
73
const MAX_LOGS = 5000
38
74
const MAX_ERRORS = 500
39
75
const MAX_METRICS = 10000
40
76
77
+
// ============================================================================
78
+
// Storage
79
+
// ============================================================================
80
+
41
81
const logs: LogEntry[] = []
42
82
const errors: Map<string, ErrorEntry> = new Map()
43
83
const metrics: MetricEntry[] = []
44
84
45
-
// Helper to generate unique IDs
85
+
// ============================================================================
86
+
// Helpers
87
+
// ============================================================================
88
+
46
89
let logCounter = 0
47
90
let errorCounter = 0
48
91
···
50
93
return `${prefix}-${Date.now()}-${counter}`
51
94
}
52
95
53
-
// Helper to extract event type from message
54
96
function extractEventType(message: string): string | undefined {
55
97
const match = message.match(/^\[([^\]]+)\]/)
56
98
return match ? match[1] : undefined
57
99
}
58
100
59
-
// Log collector
101
+
// ============================================================================
102
+
// Log Collector
103
+
// ============================================================================
104
+
60
105
export const logCollector = {
61
-
log(level: LogEntry['level'], message: string, service: string, context?: Record<string, any>, traceId?: string) {
106
+
log(
107
+
level: LogEntry['level'],
108
+
message: string,
109
+
service: string,
110
+
context?: Record<string, any>,
111
+
traceId?: string
112
+
) {
62
113
const entry: LogEntry = {
63
114
id: generateId('log', logCounter++),
64
115
timestamp: new Date(),
···
91
142
this.log('warn', message, service, context, traceId)
92
143
},
93
144
94
-
error(message: string, service: string, error?: any, context?: Record<string, any>, traceId?: string) {
145
+
error(
146
+
message: string,
147
+
service: string,
148
+
error?: any,
149
+
context?: Record<string, any>,
150
+
traceId?: string
151
+
) {
95
152
const ctx = { ...context }
96
153
if (error instanceof Error) {
97
154
ctx.error = error.message
···
106
163
},
107
164
108
165
debug(message: string, service: string, context?: Record<string, any>, traceId?: string) {
109
-
if (process.env.NODE_ENV !== 'production') {
166
+
const env = typeof Bun !== 'undefined' ? Bun.env.NODE_ENV : process.env.NODE_ENV;
167
+
if (env !== 'production') {
110
168
this.log('debug', message, service, context, traceId)
111
169
}
112
170
},
113
171
114
-
getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) {
172
+
getLogs(filter?: LogFilter) {
115
173
let filtered = [...logs]
116
174
117
175
if (filter?.level) {
···
130
188
const search = filter.search.toLowerCase()
131
189
filtered = filtered.filter(log =>
132
190
log.message.toLowerCase().includes(search) ||
133
-
JSON.stringify(log.context).toLowerCase().includes(search)
191
+
(log.context ? JSON.stringify(log.context).toLowerCase().includes(search) : false)
134
192
)
135
193
}
136
194
···
143
201
}
144
202
}
145
203
146
-
// Error tracker with deduplication
204
+
// ============================================================================
205
+
// Error Tracker
206
+
// ============================================================================
207
+
147
208
export const errorTracker = {
148
209
track(message: string, service: string, error?: any, context?: Record<string, any>) {
149
210
const key = `${service}:${message}`
···
182
243
}
183
244
},
184
245
185
-
getErrors(filter?: { service?: string; limit?: number }) {
246
+
getErrors(filter?: ErrorFilter) {
186
247
let filtered = Array.from(errors.values())
187
248
188
249
if (filter?.service) {
···
201
262
}
202
263
}
203
264
204
-
// Metrics collector
265
+
// ============================================================================
266
+
// Metrics Collector
267
+
// ============================================================================
268
+
205
269
export const metricsCollector = {
206
-
recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) {
270
+
recordRequest(
271
+
path: string,
272
+
method: string,
273
+
statusCode: number,
274
+
duration: number,
275
+
service: string
276
+
) {
207
277
const entry: MetricEntry = {
208
278
timestamp: new Date(),
209
279
path,
···
221
291
}
222
292
},
223
293
224
-
getMetrics(filter?: { service?: string; timeWindow?: number }) {
294
+
getMetrics(filter?: MetricFilter) {
225
295
let filtered = [...metrics]
226
296
227
297
if (filter?.service) {
···
236
306
return filtered
237
307
},
238
308
239
-
getStats(service?: string, timeWindow: number = 3600000) {
309
+
getStats(service?: string, timeWindow: number = 3600000): MetricStats {
240
310
const filtered = this.getMetrics({ service, timeWindow })
241
311
242
312
if (filtered.length === 0) {
···
277
347
}
278
348
}
279
349
280
-
// Hono middleware for request timing
281
-
export function observabilityMiddleware(service: string) {
282
-
return async (c: Context, next: () => Promise<void>) => {
283
-
const startTime = Date.now()
284
-
285
-
await next()
286
-
287
-
const duration = Date.now() - startTime
288
-
const { pathname } = new URL(c.req.url)
289
-
290
-
metricsCollector.recordRequest(
291
-
pathname,
292
-
c.req.method,
293
-
c.res.status,
294
-
duration,
295
-
service
296
-
)
297
-
}
298
-
}
350
+
// ============================================================================
351
+
// Logger Factory
352
+
// ============================================================================
299
353
300
-
// Hono error handler
301
-
export function observabilityErrorHandler(service: string) {
302
-
return (err: Error, c: Context) => {
303
-
const { pathname } = new URL(c.req.url)
304
-
305
-
logCollector.error(
306
-
`Request failed: ${c.req.method} ${pathname}`,
307
-
service,
308
-
err,
309
-
{ statusCode: c.res.status || 500 }
310
-
)
311
-
312
-
return c.text('Internal Server Error', 500)
354
+
/**
355
+
* Create a service-specific logger instance
356
+
*/
357
+
export function createLogger(service: string) {
358
+
return {
359
+
info: (message: string, context?: Record<string, any>) =>
360
+
logCollector.info(message, service, context),
361
+
warn: (message: string, context?: Record<string, any>) =>
362
+
logCollector.warn(message, service, context),
363
+
error: (message: string, error?: any, context?: Record<string, any>) =>
364
+
logCollector.error(message, service, error, context),
365
+
debug: (message: string, context?: Record<string, any>) =>
366
+
logCollector.debug(message, service, context)
313
367
}
314
368
}
315
-
316
-
// Export singleton logger for easy access
317
-
export const logger = {
318
-
info: (message: string, context?: Record<string, any>) =>
319
-
logCollector.info(message, 'hosting-service', context),
320
-
warn: (message: string, context?: Record<string, any>) =>
321
-
logCollector.warn(message, 'hosting-service', context),
322
-
error: (message: string, error?: any, context?: Record<string, any>) =>
323
-
logCollector.error(message, 'hosting-service', error, context),
324
-
debug: (message: string, context?: Record<string, any>) =>
325
-
logCollector.debug(message, 'hosting-service', context)
326
-
}
hosting-service/src/lib/redirects.test.ts
apps/hosting-service/src/lib/redirects.test.ts
hosting-service/src/lib/redirects.test.ts
apps/hosting-service/src/lib/redirects.test.ts
hosting-service/src/lib/redirects.ts
apps/hosting-service/src/lib/redirects.ts
hosting-service/src/lib/redirects.ts
apps/hosting-service/src/lib/redirects.ts
-187
hosting-service/src/lib/safe-fetch.ts
-187
hosting-service/src/lib/safe-fetch.ts
···
1
-
/**
2
-
* SSRF-hardened fetch utility
3
-
* Prevents requests to private networks, localhost, and enforces timeouts/size limits
4
-
*/
5
-
6
-
const BLOCKED_IP_RANGES = [
7
-
/^127\./, // 127.0.0.0/8 - Loopback
8
-
/^10\./, // 10.0.0.0/8 - Private
9
-
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 - Private
10
-
/^192\.168\./, // 192.168.0.0/16 - Private
11
-
/^169\.254\./, // 169.254.0.0/16 - Link-local
12
-
/^::1$/, // IPv6 loopback
13
-
/^fe80:/, // IPv6 link-local
14
-
/^fc00:/, // IPv6 unique local
15
-
/^fd00:/, // IPv6 unique local
16
-
];
17
-
18
-
const BLOCKED_HOSTS = [
19
-
'localhost',
20
-
'metadata.google.internal',
21
-
'169.254.169.254',
22
-
];
23
-
24
-
const FETCH_TIMEOUT = 120000; // 120 seconds
25
-
const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads
26
-
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
27
-
const MAX_JSON_SIZE = 1024 * 1024; // 1MB
28
-
const MAX_BLOB_SIZE = 500 * 1024 * 1024; // 500MB
29
-
const MAX_REDIRECTS = 10;
30
-
31
-
function isBlockedHost(hostname: string): boolean {
32
-
const lowerHost = hostname.toLowerCase();
33
-
34
-
if (BLOCKED_HOSTS.includes(lowerHost)) {
35
-
return true;
36
-
}
37
-
38
-
for (const pattern of BLOCKED_IP_RANGES) {
39
-
if (pattern.test(lowerHost)) {
40
-
return true;
41
-
}
42
-
}
43
-
44
-
return false;
45
-
}
46
-
47
-
export async function safeFetch(
48
-
url: string,
49
-
options?: RequestInit & { maxSize?: number; timeout?: number }
50
-
): Promise<Response> {
51
-
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT;
52
-
const maxSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
53
-
54
-
// Parse and validate URL
55
-
let parsedUrl: URL;
56
-
try {
57
-
parsedUrl = new URL(url);
58
-
} catch (err) {
59
-
throw new Error(`Invalid URL: ${url}`);
60
-
}
61
-
62
-
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
63
-
throw new Error(`Blocked protocol: ${parsedUrl.protocol}`);
64
-
}
65
-
66
-
const hostname = parsedUrl.hostname;
67
-
if (isBlockedHost(hostname)) {
68
-
throw new Error(`Blocked host: ${hostname}`);
69
-
}
70
-
71
-
const controller = new AbortController();
72
-
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
73
-
74
-
try {
75
-
const response = await fetch(url, {
76
-
...options,
77
-
signal: controller.signal,
78
-
redirect: 'follow',
79
-
});
80
-
81
-
const contentLength = response.headers.get('content-length');
82
-
if (contentLength && parseInt(contentLength, 10) > maxSize) {
83
-
throw new Error(`Response too large: ${contentLength} bytes`);
84
-
}
85
-
86
-
return response;
87
-
} catch (err) {
88
-
if (err instanceof Error && err.name === 'AbortError') {
89
-
throw new Error(`Request timeout after ${timeoutMs}ms`);
90
-
}
91
-
throw err;
92
-
} finally {
93
-
clearTimeout(timeoutId);
94
-
}
95
-
}
96
-
97
-
export async function safeFetchJson<T = any>(
98
-
url: string,
99
-
options?: RequestInit & { maxSize?: number; timeout?: number }
100
-
): Promise<T> {
101
-
const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE;
102
-
const response = await safeFetch(url, { ...options, maxSize: maxJsonSize });
103
-
104
-
if (!response.ok) {
105
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
106
-
}
107
-
108
-
const reader = response.body?.getReader();
109
-
if (!reader) {
110
-
throw new Error('No response body');
111
-
}
112
-
113
-
const chunks: Uint8Array[] = [];
114
-
let totalSize = 0;
115
-
116
-
try {
117
-
while (true) {
118
-
const { done, value } = await reader.read();
119
-
if (done) break;
120
-
121
-
totalSize += value.length;
122
-
if (totalSize > maxJsonSize) {
123
-
throw new Error(`Response exceeds max size: ${maxJsonSize} bytes`);
124
-
}
125
-
126
-
chunks.push(value);
127
-
}
128
-
} finally {
129
-
reader.releaseLock();
130
-
}
131
-
132
-
const combined = new Uint8Array(totalSize);
133
-
let offset = 0;
134
-
for (const chunk of chunks) {
135
-
combined.set(chunk, offset);
136
-
offset += chunk.length;
137
-
}
138
-
139
-
const text = new TextDecoder().decode(combined);
140
-
return JSON.parse(text);
141
-
}
142
-
143
-
export async function safeFetchBlob(
144
-
url: string,
145
-
options?: RequestInit & { maxSize?: number; timeout?: number }
146
-
): Promise<Uint8Array> {
147
-
const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE;
148
-
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB;
149
-
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize, timeout: timeoutMs });
150
-
151
-
if (!response.ok) {
152
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
153
-
}
154
-
155
-
const reader = response.body?.getReader();
156
-
if (!reader) {
157
-
throw new Error('No response body');
158
-
}
159
-
160
-
const chunks: Uint8Array[] = [];
161
-
let totalSize = 0;
162
-
163
-
try {
164
-
while (true) {
165
-
const { done, value } = await reader.read();
166
-
if (done) break;
167
-
168
-
totalSize += value.length;
169
-
if (totalSize > maxBlobSize) {
170
-
throw new Error(`Blob exceeds max size: ${maxBlobSize} bytes`);
171
-
}
172
-
173
-
chunks.push(value);
174
-
}
175
-
} finally {
176
-
reader.releaseLock();
177
-
}
178
-
179
-
const combined = new Uint8Array(totalSize);
180
-
let offset = 0;
181
-
for (const chunk of chunks) {
182
-
combined.set(chunk, offset);
183
-
offset += chunk.length;
184
-
}
185
-
186
-
return combined;
187
-
}
hosting-service/src/lib/types.ts
apps/hosting-service/src/lib/types.ts
hosting-service/src/lib/types.ts
apps/hosting-service/src/lib/types.ts
hosting-service/src/lib/utils.test.ts
apps/hosting-service/src/lib/utils.test.ts
hosting-service/src/lib/utils.test.ts
apps/hosting-service/src/lib/utils.test.ts
+21
-154
hosting-service/src/lib/utils.ts
apps/hosting-service/src/lib/utils.ts
+21
-154
hosting-service/src/lib/utils.ts
apps/hosting-service/src/lib/utils.ts
···
1
1
import { AtpAgent } from '@atproto/api';
2
-
import type { Record as WispFsRecord, Directory, Entry, File } from '../lexicon/types/place/wisp/fs';
3
-
import type { Record as SubfsRecord } from '../lexicon/types/place/wisp/subfs';
4
-
import type { Record as WispSettings } from '../lexicon/types/place/wisp/settings';
2
+
import type { Record as WispFsRecord, Directory, Entry, File } from '@wisp/lexicons/types/place/wisp/fs';
3
+
import type { Record as SubfsRecord } from '@wisp/lexicons/types/place/wisp/subfs';
4
+
import type { Record as WispSettings } from '@wisp/lexicons/types/place/wisp/settings';
5
5
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
6
6
import { writeFile, readFile, rename } from 'fs/promises';
7
-
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
7
+
import { safeFetchJson, safeFetchBlob } from '@wisp/safe-fetch';
8
8
import { CID } from 'multiformats';
9
+
import { extractBlobCid } from '@wisp/atproto-utils';
10
+
import { sanitizePath, collectFileCidsFromEntries } from '@wisp/fs-utils';
11
+
import { shouldCompressMimeType } from '@wisp/atproto-utils/compression';
12
+
13
+
// Re-export shared utilities for local usage and tests
14
+
export { extractBlobCid, sanitizePath };
9
15
10
16
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
11
17
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
···
21
27
settings?: WispSettings;
22
28
}
23
29
24
-
/**
25
-
* Determines if a MIME type should benefit from gzip compression.
26
-
* Returns true for text-based web assets (HTML, CSS, JS, JSON, XML, SVG).
27
-
* Returns false for already-compressed formats (images, video, audio, PDFs).
28
-
*
29
-
*/
30
-
export function shouldCompressMimeType(mimeType: string | undefined): boolean {
31
-
if (!mimeType) return false;
32
-
33
-
const mime = mimeType.toLowerCase();
34
-
35
-
// Text-based web assets and uncompressed audio that benefit from compression
36
-
const compressibleTypes = [
37
-
'text/html',
38
-
'text/css',
39
-
'text/javascript',
40
-
'application/javascript',
41
-
'application/x-javascript',
42
-
'text/xml',
43
-
'application/xml',
44
-
'application/json',
45
-
'text/plain',
46
-
'image/svg+xml',
47
-
// Uncompressed audio formats
48
-
'audio/wav',
49
-
'audio/wave',
50
-
'audio/x-wav',
51
-
'audio/aiff',
52
-
'audio/x-aiff',
53
-
];
54
-
55
-
if (compressibleTypes.some(type => mime === type || mime.startsWith(type))) {
56
-
return true;
57
-
}
58
-
59
-
// Already-compressed formats that should NOT be double-compressed
60
-
const alreadyCompressedPrefixes = [
61
-
'video/',
62
-
'audio/',
63
-
'image/',
64
-
'application/pdf',
65
-
'application/zip',
66
-
'application/gzip',
67
-
];
68
-
69
-
if (alreadyCompressedPrefixes.some(prefix => mime.startsWith(prefix))) {
70
-
return false;
71
-
}
72
-
73
-
// Default to not compressing for unknown types
74
-
return false;
75
-
}
76
-
77
-
interface IpldLink {
78
-
$link: string;
79
-
}
80
-
81
-
interface TypedBlobRef {
82
-
ref: CID | IpldLink;
83
-
}
84
-
85
-
interface UntypedBlobRef {
86
-
cid: string;
87
-
}
88
-
89
-
function isIpldLink(obj: unknown): obj is IpldLink {
90
-
return typeof obj === 'object' && obj !== null && '$link' in obj && typeof (obj as IpldLink).$link === 'string';
91
-
}
92
-
93
-
function isTypedBlobRef(obj: unknown): obj is TypedBlobRef {
94
-
return typeof obj === 'object' && obj !== null && 'ref' in obj;
95
-
}
96
-
97
-
function isUntypedBlobRef(obj: unknown): obj is UntypedBlobRef {
98
-
return typeof obj === 'object' && obj !== null && 'cid' in obj && typeof (obj as UntypedBlobRef).cid === 'string';
99
-
}
100
30
101
31
export async function resolveDid(identifier: string): Promise<string | null> {
102
32
try {
···
189
119
}
190
120
}
191
121
192
-
export function extractBlobCid(blobRef: unknown): string | null {
193
-
if (isIpldLink(blobRef)) {
194
-
return blobRef.$link;
195
-
}
196
-
197
-
if (isTypedBlobRef(blobRef)) {
198
-
const ref = blobRef.ref;
199
-
200
-
const cid = CID.asCID(ref);
201
-
if (cid) {
202
-
return cid.toString();
203
-
}
204
-
205
-
if (isIpldLink(ref)) {
206
-
return ref.$link;
207
-
}
208
-
}
209
-
210
-
if (isUntypedBlobRef(blobRef)) {
211
-
return blobRef.cid;
212
-
}
213
-
214
-
return null;
215
-
}
216
-
217
122
/**
218
123
* Extract all subfs URIs from a directory tree with their mount paths
219
124
*/
···
253
158
return null;
254
159
}
255
160
256
-
const did = parts[0];
257
-
const collection = parts[1];
258
-
const rkey = parts[2];
161
+
const did = parts[0] || '';
162
+
const collection = parts[1] || '';
163
+
const rkey = parts[2] || '';
259
164
260
165
// Fetch the record from PDS
261
166
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`;
···
297
202
);
298
203
299
204
// Build a map of path -> root entries to merge
205
+
// Note: SubFS entries are compatible with FS entries at runtime
300
206
const subfsMap = new Map<string, Entry[]>();
301
207
for (const { record, path } of subfsRecords) {
302
208
if (record && record.root && record.root.entries) {
303
-
subfsMap.set(path, record.root.entries);
209
+
subfsMap.set(path, record.root.entries as unknown as Entry[]);
304
210
}
305
211
}
306
212
···
328
234
} else {
329
235
// Subdirectory merge: create a directory with the subfs node's name
330
236
const processedEntries = replaceSubfsInEntries(subfsEntries, fullPath);
237
+
const directoryNode: Directory = {
238
+
type: 'directory',
239
+
entries: processedEntries
240
+
};
331
241
result.push({
332
242
name: entry.name,
333
-
node: {
334
-
type: 'directory',
335
-
entries: processedEntries
336
-
}
243
+
node: directoryNode as any // Type assertion needed due to lexicon type complexity
337
244
});
338
245
}
339
246
} else {
···
363
270
entries: replaceSubfsInEntries(directory.entries)
364
271
};
365
272
}
273
+
366
274
367
275
export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> {
368
276
console.log('Caching site', did, rkey);
···
436
344
}
437
345
}
438
346
439
-
/**
440
-
* Recursively collect file CIDs from entries for incremental update tracking
441
-
*/
442
-
function collectFileCidsFromEntries(entries: Entry[], pathPrefix: string, fileCids: Record<string, string>): void {
443
-
for (const entry of entries) {
444
-
const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name;
445
-
const node = entry.node;
446
-
447
-
if ('type' in node && node.type === 'directory' && 'entries' in node) {
448
-
collectFileCidsFromEntries(node.entries, currentPath, fileCids);
449
-
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
450
-
const fileNode = node as File;
451
-
const cid = extractBlobCid(fileNode.blob);
452
-
if (cid) {
453
-
fileCids[currentPath] = cid;
454
-
}
455
-
}
456
-
}
457
-
}
458
347
459
348
async function cacheFiles(
460
349
did: string,
···
661
550
}
662
551
}
663
552
664
-
/**
665
-
* Sanitize a file path to prevent directory traversal attacks
666
-
* Removes any path segments that attempt to go up directories
667
-
*/
668
-
export function sanitizePath(filePath: string): string {
669
-
// Remove leading slashes
670
-
let cleaned = filePath.replace(/^\/+/, '');
671
-
672
-
// Split into segments and filter out dangerous ones
673
-
const segments = cleaned.split('/').filter(segment => {
674
-
// Remove empty segments
675
-
if (!segment || segment === '.') return false;
676
-
// Remove parent directory references
677
-
if (segment === '..') return false;
678
-
// Remove segments with null bytes
679
-
if (segment.includes('\0')) return false;
680
-
return true;
681
-
});
682
-
683
-
// Rejoin the safe segments
684
-
return segments.join('/');
685
-
}
686
553
687
554
export function getCachedFilePath(did: string, site: string, filePath: string): string {
688
555
const sanitizedPath = sanitizePath(filePath);
-1523
hosting-service/src/server.ts
-1523
hosting-service/src/server.ts
···
1
-
import { Hono } from 'hono';
2
-
import { cors } from 'hono/cors';
3
-
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
4
-
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType, getCachedSettings } from './lib/utils';
5
-
import type { Record as WispSettings } from './lexicon/types/place/wisp/settings';
6
-
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
7
-
import { existsSync } from 'fs';
8
-
import { readFile, access } from 'fs/promises';
9
-
import { lookup } from 'mime-types';
10
-
import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability';
11
-
import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, type FileMetadata, markSiteAsBeingCached, unmarkSiteAsBeingCached, isSiteBeingCached } from './lib/cache';
12
-
import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString, type RedirectRule } from './lib/redirects';
13
-
14
-
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
15
-
16
-
/**
17
-
* Default index file names to check for directory requests
18
-
* Will be checked in order until one is found
19
-
*/
20
-
const DEFAULT_INDEX_FILES = ['index.html', 'index.htm'];
21
-
22
-
/**
23
-
* Get index files list from settings or use defaults
24
-
*/
25
-
function getIndexFiles(settings: WispSettings | null): string[] {
26
-
if (settings?.indexFiles && settings.indexFiles.length > 0) {
27
-
return settings.indexFiles;
28
-
}
29
-
return DEFAULT_INDEX_FILES;
30
-
}
31
-
32
-
/**
33
-
* Match a file path against a glob pattern
34
-
* Supports * wildcard and basic path matching
35
-
*/
36
-
function matchGlob(path: string, pattern: string): boolean {
37
-
// Normalize paths
38
-
const normalizedPath = path.startsWith('/') ? path : '/' + path;
39
-
const normalizedPattern = pattern.startsWith('/') ? pattern : '/' + pattern;
40
-
41
-
// Convert glob pattern to regex
42
-
const regexPattern = normalizedPattern
43
-
.replace(/\./g, '\\.')
44
-
.replace(/\*/g, '.*')
45
-
.replace(/\?/g, '.');
46
-
47
-
const regex = new RegExp('^' + regexPattern + '$');
48
-
return regex.test(normalizedPath);
49
-
}
50
-
51
-
/**
52
-
* Apply custom headers from settings to response headers
53
-
*/
54
-
function applyCustomHeaders(headers: Record<string, string>, filePath: string, settings: WispSettings | null) {
55
-
if (!settings?.headers || settings.headers.length === 0) return;
56
-
57
-
for (const customHeader of settings.headers) {
58
-
// If path glob is specified, check if it matches
59
-
if (customHeader.path) {
60
-
if (!matchGlob(filePath, customHeader.path)) {
61
-
continue;
62
-
}
63
-
}
64
-
// Apply the header
65
-
headers[customHeader.name] = customHeader.value;
66
-
}
67
-
}
68
-
69
-
/**
70
-
* Generate 404 page HTML
71
-
*/
72
-
function generate404Page(): string {
73
-
const html = `<!DOCTYPE html>
74
-
<html>
75
-
<head>
76
-
<meta charset="utf-8">
77
-
<meta name="viewport" content="width=device-width, initial-scale=1">
78
-
<title>404 - Not Found</title>
79
-
<style>
80
-
@media (prefers-color-scheme: light) {
81
-
:root {
82
-
/* Warm beige background */
83
-
--background: oklch(0.90 0.012 35);
84
-
/* Very dark brown text */
85
-
--foreground: oklch(0.18 0.01 30);
86
-
--border: oklch(0.75 0.015 30);
87
-
/* Bright pink accent for links */
88
-
--accent: oklch(0.78 0.15 345);
89
-
}
90
-
}
91
-
@media (prefers-color-scheme: dark) {
92
-
:root {
93
-
/* Slate violet background */
94
-
--background: oklch(0.23 0.015 285);
95
-
/* Light gray text */
96
-
--foreground: oklch(0.90 0.005 285);
97
-
/* Subtle borders */
98
-
--border: oklch(0.38 0.02 285);
99
-
/* Soft pink accent */
100
-
--accent: oklch(0.85 0.08 5);
101
-
}
102
-
}
103
-
body {
104
-
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
105
-
background: var(--background);
106
-
color: var(--foreground);
107
-
padding: 2rem;
108
-
max-width: 800px;
109
-
margin: 0 auto;
110
-
display: flex;
111
-
flex-direction: column;
112
-
min-height: 100vh;
113
-
justify-content: center;
114
-
align-items: center;
115
-
text-align: center;
116
-
}
117
-
h1 {
118
-
font-size: 6rem;
119
-
margin: 0;
120
-
font-weight: 700;
121
-
line-height: 1;
122
-
}
123
-
h2 {
124
-
font-size: 1.5rem;
125
-
margin: 1rem 0 2rem;
126
-
font-weight: 400;
127
-
opacity: 0.8;
128
-
}
129
-
p {
130
-
font-size: 1rem;
131
-
opacity: 0.7;
132
-
margin-bottom: 2rem;
133
-
}
134
-
a {
135
-
color: var(--accent);
136
-
text-decoration: none;
137
-
font-size: 1rem;
138
-
}
139
-
a:hover {
140
-
text-decoration: underline;
141
-
}
142
-
footer {
143
-
margin-top: 2rem;
144
-
padding-top: 1.5rem;
145
-
border-top: 1px solid var(--border);
146
-
text-align: center;
147
-
font-size: 0.875rem;
148
-
opacity: 0.7;
149
-
color: var(--foreground);
150
-
}
151
-
footer a {
152
-
color: var(--accent);
153
-
text-decoration: none;
154
-
display: inline;
155
-
}
156
-
footer a:hover {
157
-
text-decoration: underline;
158
-
}
159
-
</style>
160
-
</head>
161
-
<body>
162
-
<div>
163
-
<h1>404</h1>
164
-
<h2>Page not found</h2>
165
-
<p>The page you're looking for doesn't exist.</p>
166
-
<a href="/">← Back to home</a>
167
-
</div>
168
-
<footer>
169
-
Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a>
170
-
</footer>
171
-
</body>
172
-
</html>`;
173
-
return html;
174
-
}
175
-
176
-
/**
177
-
* Generate directory listing HTML
178
-
*/
179
-
function generateDirectoryListing(path: string, entries: Array<{name: string, isDirectory: boolean}>): string {
180
-
const title = path || 'Index';
181
-
182
-
// Sort: directories first, then files, alphabetically within each group
183
-
const sortedEntries = [...entries].sort((a, b) => {
184
-
if (a.isDirectory && !b.isDirectory) return -1;
185
-
if (!a.isDirectory && b.isDirectory) return 1;
186
-
return a.name.localeCompare(b.name);
187
-
});
188
-
189
-
const html = `<!DOCTYPE html>
190
-
<html>
191
-
<head>
192
-
<meta charset="utf-8">
193
-
<meta name="viewport" content="width=device-width, initial-scale=1">
194
-
<title>Index of /${path}</title>
195
-
<style>
196
-
@media (prefers-color-scheme: light) {
197
-
:root {
198
-
/* Warm beige background */
199
-
--background: oklch(0.90 0.012 35);
200
-
/* Very dark brown text */
201
-
--foreground: oklch(0.18 0.01 30);
202
-
--border: oklch(0.75 0.015 30);
203
-
/* Bright pink accent for links */
204
-
--accent: oklch(0.78 0.15 345);
205
-
/* Lavender for folders */
206
-
--folder: oklch(0.60 0.12 295);
207
-
--icon: oklch(0.28 0.01 30);
208
-
}
209
-
}
210
-
@media (prefers-color-scheme: dark) {
211
-
:root {
212
-
/* Slate violet background */
213
-
--background: oklch(0.23 0.015 285);
214
-
/* Light gray text */
215
-
--foreground: oklch(0.90 0.005 285);
216
-
/* Subtle borders */
217
-
--border: oklch(0.38 0.02 285);
218
-
/* Soft pink accent */
219
-
--accent: oklch(0.85 0.08 5);
220
-
/* Lavender for folders */
221
-
--folder: oklch(0.70 0.10 295);
222
-
--icon: oklch(0.85 0.005 285);
223
-
}
224
-
}
225
-
body {
226
-
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
227
-
background: var(--background);
228
-
color: var(--foreground);
229
-
padding: 2rem;
230
-
max-width: 800px;
231
-
margin: 0 auto;
232
-
}
233
-
h1 {
234
-
font-size: 1.5rem;
235
-
margin-bottom: 2rem;
236
-
padding-bottom: 0.5rem;
237
-
border-bottom: 1px solid var(--border);
238
-
}
239
-
ul {
240
-
list-style: none;
241
-
padding: 0;
242
-
}
243
-
li {
244
-
padding: 0.5rem 0;
245
-
border-bottom: 1px solid var(--border);
246
-
}
247
-
li:last-child {
248
-
border-bottom: none;
249
-
}
250
-
li a {
251
-
color: var(--accent);
252
-
text-decoration: none;
253
-
display: flex;
254
-
align-items: center;
255
-
gap: 0.75rem;
256
-
}
257
-
li a:hover {
258
-
text-decoration: underline;
259
-
}
260
-
.folder {
261
-
color: var(--folder);
262
-
font-weight: 600;
263
-
}
264
-
.file {
265
-
color: var(--accent);
266
-
}
267
-
.folder::before,
268
-
.file::before,
269
-
.parent::before {
270
-
content: "";
271
-
display: inline-block;
272
-
width: 1.25em;
273
-
height: 1.25em;
274
-
background-color: var(--icon);
275
-
flex-shrink: 0;
276
-
-webkit-mask-size: contain;
277
-
mask-size: contain;
278
-
-webkit-mask-repeat: no-repeat;
279
-
mask-repeat: no-repeat;
280
-
-webkit-mask-position: center;
281
-
mask-position: center;
282
-
}
283
-
.folder::before {
284
-
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>');
285
-
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>');
286
-
}
287
-
.file::before {
288
-
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>');
289
-
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>');
290
-
}
291
-
.parent::before {
292
-
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>');
293
-
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>');
294
-
}
295
-
footer {
296
-
margin-top: 2rem;
297
-
padding-top: 1.5rem;
298
-
border-top: 1px solid var(--border);
299
-
text-align: center;
300
-
font-size: 0.875rem;
301
-
opacity: 0.7;
302
-
color: var(--foreground);
303
-
}
304
-
footer a {
305
-
color: var(--accent);
306
-
text-decoration: none;
307
-
display: inline;
308
-
}
309
-
footer a:hover {
310
-
text-decoration: underline;
311
-
}
312
-
</style>
313
-
</head>
314
-
<body>
315
-
<h1>Index of /${path}</h1>
316
-
<ul>
317
-
${path ? '<li><a href="../" class="parent">../</a></li>' : ''}
318
-
${sortedEntries.map(e =>
319
-
`<li><a href="${e.name}${e.isDirectory ? '/' : ''}" class="${e.isDirectory ? 'folder' : 'file'}">${e.name}${e.isDirectory ? '/' : ''}</a></li>`
320
-
).join('\n ')}
321
-
</ul>
322
-
<footer>
323
-
Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a>
324
-
</footer>
325
-
</body>
326
-
</html>`;
327
-
return html;
328
-
}
329
-
330
-
/**
331
-
* Validate site name (rkey) to prevent injection attacks
332
-
* Must match AT Protocol rkey format
333
-
*/
334
-
function isValidRkey(rkey: string): boolean {
335
-
if (!rkey || typeof rkey !== 'string') return false;
336
-
if (rkey.length < 1 || rkey.length > 512) return false;
337
-
if (rkey === '.' || rkey === '..') return false;
338
-
if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false;
339
-
const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/;
340
-
return validRkeyPattern.test(rkey);
341
-
}
342
-
343
-
/**
344
-
* Async file existence check
345
-
*/
346
-
async function fileExists(path: string): Promise<boolean> {
347
-
try {
348
-
await access(path);
349
-
return true;
350
-
} catch {
351
-
return false;
352
-
}
353
-
}
354
-
355
-
/**
356
-
* Return a response indicating the site is being updated
357
-
*/
358
-
function siteUpdatingResponse(): Response {
359
-
const html = `<!DOCTYPE html>
360
-
<html>
361
-
<head>
362
-
<meta charset="utf-8">
363
-
<meta name="viewport" content="width=device-width, initial-scale=1">
364
-
<title>Site Updating</title>
365
-
<style>
366
-
@media (prefers-color-scheme: light) {
367
-
:root {
368
-
--background: oklch(0.90 0.012 35);
369
-
--foreground: oklch(0.18 0.01 30);
370
-
--primary: oklch(0.35 0.02 35);
371
-
--accent: oklch(0.78 0.15 345);
372
-
}
373
-
}
374
-
@media (prefers-color-scheme: dark) {
375
-
:root {
376
-
--background: oklch(0.23 0.015 285);
377
-
--foreground: oklch(0.90 0.005 285);
378
-
--primary: oklch(0.70 0.10 295);
379
-
--accent: oklch(0.85 0.08 5);
380
-
}
381
-
}
382
-
body {
383
-
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
384
-
display: flex;
385
-
align-items: center;
386
-
justify-content: center;
387
-
min-height: 100vh;
388
-
margin: 0;
389
-
background: var(--background);
390
-
color: var(--foreground);
391
-
}
392
-
.container {
393
-
text-align: center;
394
-
padding: 2rem;
395
-
max-width: 500px;
396
-
}
397
-
h1 {
398
-
font-size: 2.5rem;
399
-
margin-bottom: 1rem;
400
-
font-weight: 600;
401
-
color: var(--primary);
402
-
}
403
-
p {
404
-
font-size: 1.25rem;
405
-
opacity: 0.8;
406
-
margin-bottom: 2rem;
407
-
color: var(--foreground);
408
-
}
409
-
.spinner {
410
-
border: 4px solid var(--accent);
411
-
border-radius: 50%;
412
-
border-top: 4px solid var(--primary);
413
-
width: 40px;
414
-
height: 40px;
415
-
animation: spin 1s linear infinite;
416
-
margin: 0 auto;
417
-
}
418
-
@keyframes spin {
419
-
0% { transform: rotate(0deg); }
420
-
100% { transform: rotate(360deg); }
421
-
}
422
-
</style>
423
-
<meta http-equiv="refresh" content="3">
424
-
</head>
425
-
<body>
426
-
<div class="container">
427
-
<h1>Site Updating</h1>
428
-
<p>This site is undergoing an update right now. Check back in a moment...</p>
429
-
<div class="spinner"></div>
430
-
</div>
431
-
</body>
432
-
</html>`;
433
-
434
-
return new Response(html, {
435
-
status: 503,
436
-
headers: {
437
-
'Content-Type': 'text/html; charset=utf-8',
438
-
'Cache-Control': 'no-store, no-cache, must-revalidate',
439
-
'Retry-After': '3',
440
-
},
441
-
});
442
-
}
443
-
444
-
// Cache for redirect rules (per site)
445
-
const redirectRulesCache = new Map<string, RedirectRule[]>();
446
-
447
-
/**
448
-
* Clear redirect rules cache for a specific site
449
-
* Should be called when a site is updated/recached
450
-
*/
451
-
export function clearRedirectRulesCache(did: string, rkey: string) {
452
-
const cacheKey = `${did}:${rkey}`;
453
-
redirectRulesCache.delete(cacheKey);
454
-
}
455
-
456
-
// Helper to serve files from cache
457
-
async function serveFromCache(
458
-
did: string,
459
-
rkey: string,
460
-
filePath: string,
461
-
fullUrl?: string,
462
-
headers?: Record<string, string>
463
-
) {
464
-
// Load settings for this site
465
-
const settings = await getCachedSettings(did, rkey);
466
-
const indexFiles = getIndexFiles(settings);
467
-
468
-
// Check for redirect rules first (_redirects wins over settings)
469
-
const redirectCacheKey = `${did}:${rkey}`;
470
-
let redirectRules = redirectRulesCache.get(redirectCacheKey);
471
-
472
-
if (redirectRules === undefined) {
473
-
// Load rules for the first time
474
-
redirectRules = await loadRedirectRules(did, rkey);
475
-
redirectRulesCache.set(redirectCacheKey, redirectRules);
476
-
}
477
-
478
-
// Apply redirect rules if any exist
479
-
if (redirectRules.length > 0) {
480
-
const requestPath = '/' + (filePath || '');
481
-
const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
482
-
const cookies = parseCookies(headers?.['cookie']);
483
-
484
-
const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
485
-
queryParams,
486
-
headers,
487
-
cookies,
488
-
});
489
-
490
-
if (redirectMatch) {
491
-
const { rule, targetPath, status } = redirectMatch;
492
-
493
-
// If not forced, check if the requested file exists before redirecting
494
-
if (!rule.force) {
495
-
// Build the expected file path
496
-
let checkPath = filePath || indexFiles[0];
497
-
if (checkPath.endsWith('/')) {
498
-
checkPath += indexFiles[0];
499
-
}
500
-
501
-
const cachedFile = getCachedFilePath(did, rkey, checkPath);
502
-
const fileExistsOnDisk = await fileExists(cachedFile);
503
-
504
-
// If file exists and redirect is not forced, serve the file normally
505
-
if (fileExistsOnDisk) {
506
-
return serveFileInternal(did, rkey, filePath, settings);
507
-
}
508
-
}
509
-
510
-
// Handle different status codes
511
-
if (status === 200) {
512
-
// Rewrite: serve different content but keep URL the same
513
-
// Remove leading slash for internal path resolution
514
-
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
515
-
return serveFileInternal(did, rkey, rewritePath, settings);
516
-
} else if (status === 301 || status === 302) {
517
-
// External redirect: change the URL
518
-
return new Response(null, {
519
-
status,
520
-
headers: {
521
-
'Location': targetPath,
522
-
'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
523
-
},
524
-
});
525
-
} else if (status === 404) {
526
-
// Custom 404 page from _redirects (wins over settings.custom404)
527
-
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
528
-
const response = await serveFileInternal(did, rkey, custom404Path, settings);
529
-
// Override status to 404
530
-
return new Response(response.body, {
531
-
status: 404,
532
-
headers: response.headers,
533
-
});
534
-
}
535
-
}
536
-
}
537
-
538
-
// No redirect matched, serve normally with settings
539
-
return serveFileInternal(did, rkey, filePath, settings);
540
-
}
541
-
542
-
// Internal function to serve a file (used by both normal serving and rewrites)
543
-
async function serveFileInternal(did: string, rkey: string, filePath: string, settings: WispSettings | null = null) {
544
-
// Check if site is currently being cached - if so, return updating response
545
-
if (isSiteBeingCached(did, rkey)) {
546
-
return siteUpdatingResponse();
547
-
}
548
-
549
-
const indexFiles = getIndexFiles(settings);
550
-
551
-
// Normalize the request path (keep empty for root, remove trailing slash for others)
552
-
let requestPath = filePath || '';
553
-
if (requestPath.endsWith('/') && requestPath.length > 1) {
554
-
requestPath = requestPath.slice(0, -1);
555
-
}
556
-
557
-
// Check if this path is a directory first
558
-
const directoryPath = getCachedFilePath(did, rkey, requestPath);
559
-
if (await fileExists(directoryPath)) {
560
-
const { stat, readdir } = await import('fs/promises');
561
-
try {
562
-
const stats = await stat(directoryPath);
563
-
if (stats.isDirectory()) {
564
-
// It's a directory, try each index file in order
565
-
for (const indexFile of indexFiles) {
566
-
const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile;
567
-
const indexFilePath = getCachedFilePath(did, rkey, indexPath);
568
-
if (await fileExists(indexFilePath)) {
569
-
return serveFileInternal(did, rkey, indexPath, settings);
570
-
}
571
-
}
572
-
// No index file found - check if directory listing is enabled
573
-
if (settings?.directoryListing) {
574
-
const { stat } = await import('fs/promises');
575
-
const entries = await readdir(directoryPath);
576
-
// Filter out .meta files and other hidden files
577
-
const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json');
578
-
579
-
// Check which entries are directories
580
-
const entriesWithType = await Promise.all(
581
-
visibleEntries.map(async (name) => {
582
-
try {
583
-
const entryPath = `${directoryPath}/${name}`;
584
-
const stats = await stat(entryPath);
585
-
return { name, isDirectory: stats.isDirectory() };
586
-
} catch {
587
-
return { name, isDirectory: false };
588
-
}
589
-
})
590
-
);
591
-
592
-
const html = generateDirectoryListing(requestPath, entriesWithType);
593
-
return new Response(html, {
594
-
headers: {
595
-
'Content-Type': 'text/html; charset=utf-8',
596
-
'Cache-Control': 'public, max-age=300',
597
-
},
598
-
});
599
-
}
600
-
// Fall through to 404/SPA handling
601
-
}
602
-
} catch (err) {
603
-
// If stat fails, continue with normal flow
604
-
}
605
-
}
606
-
607
-
// Not a directory, try to serve as a file
608
-
const fileRequestPath = requestPath || indexFiles[0];
609
-
const cacheKey = getCacheKey(did, rkey, fileRequestPath);
610
-
const cachedFile = getCachedFilePath(did, rkey, fileRequestPath);
611
-
612
-
// Check in-memory cache first
613
-
let content = fileCache.get(cacheKey);
614
-
let meta = metadataCache.get(cacheKey);
615
-
616
-
if (!content && await fileExists(cachedFile)) {
617
-
// Read from disk and cache
618
-
content = await readFile(cachedFile);
619
-
fileCache.set(cacheKey, content, content.length);
620
-
621
-
const metaFile = `${cachedFile}.meta`;
622
-
if (await fileExists(metaFile)) {
623
-
const metaJson = await readFile(metaFile, 'utf-8');
624
-
meta = JSON.parse(metaJson);
625
-
metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length);
626
-
}
627
-
}
628
-
629
-
if (content) {
630
-
// Build headers with caching
631
-
const headers: Record<string, string> = {};
632
-
633
-
if (meta && meta.encoding === 'gzip' && meta.mimeType) {
634
-
const shouldServeCompressed = shouldCompressMimeType(meta.mimeType);
635
-
636
-
if (!shouldServeCompressed) {
637
-
// Verify content is actually gzipped before attempting decompression
638
-
const isGzipped = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
639
-
if (isGzipped) {
640
-
const { gunzipSync } = await import('zlib');
641
-
const decompressed = gunzipSync(content);
642
-
headers['Content-Type'] = meta.mimeType;
643
-
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
644
-
applyCustomHeaders(headers, fileRequestPath, settings);
645
-
return new Response(decompressed, { headers });
646
-
} else {
647
-
// Meta says gzipped but content isn't - serve as-is
648
-
console.warn(`File ${filePath} has gzip encoding in meta but content lacks gzip magic bytes`);
649
-
headers['Content-Type'] = meta.mimeType;
650
-
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
651
-
applyCustomHeaders(headers, fileRequestPath, settings);
652
-
return new Response(content, { headers });
653
-
}
654
-
}
655
-
656
-
headers['Content-Type'] = meta.mimeType;
657
-
headers['Content-Encoding'] = 'gzip';
658
-
headers['Cache-Control'] = meta.mimeType.startsWith('text/html')
659
-
? 'public, max-age=300'
660
-
: 'public, max-age=31536000, immutable';
661
-
applyCustomHeaders(headers, fileRequestPath, settings);
662
-
return new Response(content, { headers });
663
-
}
664
-
665
-
// Non-compressed files
666
-
const mimeType = lookup(cachedFile) || 'application/octet-stream';
667
-
headers['Content-Type'] = mimeType;
668
-
headers['Cache-Control'] = mimeType.startsWith('text/html')
669
-
? 'public, max-age=300'
670
-
: 'public, max-age=31536000, immutable';
671
-
applyCustomHeaders(headers, fileRequestPath, settings);
672
-
return new Response(content, { headers });
673
-
}
674
-
675
-
// Try index files for directory-like paths
676
-
if (!fileRequestPath.includes('.')) {
677
-
for (const indexFileName of indexFiles) {
678
-
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
679
-
const indexCacheKey = getCacheKey(did, rkey, indexPath);
680
-
const indexFile = getCachedFilePath(did, rkey, indexPath);
681
-
682
-
let indexContent = fileCache.get(indexCacheKey);
683
-
let indexMeta = metadataCache.get(indexCacheKey);
684
-
685
-
if (!indexContent && await fileExists(indexFile)) {
686
-
indexContent = await readFile(indexFile);
687
-
fileCache.set(indexCacheKey, indexContent, indexContent.length);
688
-
689
-
const indexMetaFile = `${indexFile}.meta`;
690
-
if (await fileExists(indexMetaFile)) {
691
-
const metaJson = await readFile(indexMetaFile, 'utf-8');
692
-
indexMeta = JSON.parse(metaJson);
693
-
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
694
-
}
695
-
}
696
-
697
-
if (indexContent) {
698
-
const headers: Record<string, string> = {
699
-
'Content-Type': 'text/html; charset=utf-8',
700
-
'Cache-Control': 'public, max-age=300',
701
-
};
702
-
703
-
if (indexMeta && indexMeta.encoding === 'gzip') {
704
-
headers['Content-Encoding'] = 'gzip';
705
-
}
706
-
707
-
applyCustomHeaders(headers, indexPath, settings);
708
-
return new Response(indexContent, { headers });
709
-
}
710
-
}
711
-
}
712
-
713
-
// Try clean URLs: /about -> /about.html
714
-
if (settings?.cleanUrls && !fileRequestPath.includes('.')) {
715
-
const htmlPath = `${fileRequestPath}.html`;
716
-
const htmlFile = getCachedFilePath(did, rkey, htmlPath);
717
-
if (await fileExists(htmlFile)) {
718
-
return serveFileInternal(did, rkey, htmlPath, settings);
719
-
}
720
-
721
-
// Also try /about/index.html
722
-
for (const indexFileName of indexFiles) {
723
-
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
724
-
const indexFile = getCachedFilePath(did, rkey, indexPath);
725
-
if (await fileExists(indexFile)) {
726
-
return serveFileInternal(did, rkey, indexPath, settings);
727
-
}
728
-
}
729
-
}
730
-
731
-
// SPA mode: serve SPA file for all non-existing routes (wins over custom404 but loses to _redirects)
732
-
if (settings?.spaMode) {
733
-
const spaFile = settings.spaMode;
734
-
const spaFilePath = getCachedFilePath(did, rkey, spaFile);
735
-
if (await fileExists(spaFilePath)) {
736
-
return serveFileInternal(did, rkey, spaFile, settings);
737
-
}
738
-
}
739
-
740
-
// Custom 404: serve custom 404 file if configured (wins conflict battle)
741
-
if (settings?.custom404) {
742
-
const custom404File = settings.custom404;
743
-
const custom404Path = getCachedFilePath(did, rkey, custom404File);
744
-
if (await fileExists(custom404Path)) {
745
-
const response = await serveFileInternal(did, rkey, custom404File, settings);
746
-
// Override status to 404
747
-
return new Response(response.body, {
748
-
status: 404,
749
-
headers: response.headers,
750
-
});
751
-
}
752
-
}
753
-
754
-
// Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html)
755
-
const auto404Pages = ['404.html', 'not_found.html'];
756
-
for (const auto404Page of auto404Pages) {
757
-
const auto404Path = getCachedFilePath(did, rkey, auto404Page);
758
-
if (await fileExists(auto404Path)) {
759
-
const response = await serveFileInternal(did, rkey, auto404Page, settings);
760
-
// Override status to 404
761
-
return new Response(response.body, {
762
-
status: 404,
763
-
headers: response.headers,
764
-
});
765
-
}
766
-
}
767
-
768
-
// Directory listing fallback: if enabled, show root directory listing on 404
769
-
if (settings?.directoryListing) {
770
-
const rootPath = getCachedFilePath(did, rkey, '');
771
-
if (await fileExists(rootPath)) {
772
-
const { stat, readdir } = await import('fs/promises');
773
-
try {
774
-
const stats = await stat(rootPath);
775
-
if (stats.isDirectory()) {
776
-
const entries = await readdir(rootPath);
777
-
// Filter out .meta files and metadata
778
-
const visibleEntries = entries.filter(entry =>
779
-
!entry.endsWith('.meta') && entry !== '.metadata.json'
780
-
);
781
-
782
-
// Check which entries are directories
783
-
const entriesWithType = await Promise.all(
784
-
visibleEntries.map(async (name) => {
785
-
try {
786
-
const entryPath = `${rootPath}/${name}`;
787
-
const entryStats = await stat(entryPath);
788
-
return { name, isDirectory: entryStats.isDirectory() };
789
-
} catch {
790
-
return { name, isDirectory: false };
791
-
}
792
-
})
793
-
);
794
-
795
-
const html = generateDirectoryListing('', entriesWithType);
796
-
return new Response(html, {
797
-
status: 404,
798
-
headers: {
799
-
'Content-Type': 'text/html; charset=utf-8',
800
-
'Cache-Control': 'public, max-age=300',
801
-
},
802
-
});
803
-
}
804
-
} catch (err) {
805
-
// If directory listing fails, fall through to 404
806
-
}
807
-
}
808
-
}
809
-
810
-
// Default styled 404 page
811
-
const html = generate404Page();
812
-
return new Response(html, {
813
-
status: 404,
814
-
headers: {
815
-
'Content-Type': 'text/html; charset=utf-8',
816
-
'Cache-Control': 'public, max-age=300',
817
-
},
818
-
});
819
-
}
820
-
821
-
// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes
822
-
async function serveFromCacheWithRewrite(
823
-
did: string,
824
-
rkey: string,
825
-
filePath: string,
826
-
basePath: string,
827
-
fullUrl?: string,
828
-
headers?: Record<string, string>
829
-
) {
830
-
// Load settings for this site
831
-
const settings = await getCachedSettings(did, rkey);
832
-
const indexFiles = getIndexFiles(settings);
833
-
834
-
// Check for redirect rules first (_redirects wins over settings)
835
-
const redirectCacheKey = `${did}:${rkey}`;
836
-
let redirectRules = redirectRulesCache.get(redirectCacheKey);
837
-
838
-
if (redirectRules === undefined) {
839
-
// Load rules for the first time
840
-
redirectRules = await loadRedirectRules(did, rkey);
841
-
redirectRulesCache.set(redirectCacheKey, redirectRules);
842
-
}
843
-
844
-
// Apply redirect rules if any exist
845
-
if (redirectRules.length > 0) {
846
-
const requestPath = '/' + (filePath || '');
847
-
const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
848
-
const cookies = parseCookies(headers?.['cookie']);
849
-
850
-
const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
851
-
queryParams,
852
-
headers,
853
-
cookies,
854
-
});
855
-
856
-
if (redirectMatch) {
857
-
const { rule, targetPath, status } = redirectMatch;
858
-
859
-
// If not forced, check if the requested file exists before redirecting
860
-
if (!rule.force) {
861
-
// Build the expected file path
862
-
let checkPath = filePath || indexFiles[0];
863
-
if (checkPath.endsWith('/')) {
864
-
checkPath += indexFiles[0];
865
-
}
866
-
867
-
const cachedFile = getCachedFilePath(did, rkey, checkPath);
868
-
const fileExistsOnDisk = await fileExists(cachedFile);
869
-
870
-
// If file exists and redirect is not forced, serve the file normally
871
-
if (fileExistsOnDisk) {
872
-
return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
873
-
}
874
-
}
875
-
876
-
// Handle different status codes
877
-
if (status === 200) {
878
-
// Rewrite: serve different content but keep URL the same
879
-
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
880
-
return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath, settings);
881
-
} else if (status === 301 || status === 302) {
882
-
// External redirect: change the URL
883
-
// For sites.wisp.place, we need to adjust the target path to include the base path
884
-
// unless it's an absolute URL
885
-
let redirectTarget = targetPath;
886
-
if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) {
887
-
redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath);
888
-
}
889
-
return new Response(null, {
890
-
status,
891
-
headers: {
892
-
'Location': redirectTarget,
893
-
'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
894
-
},
895
-
});
896
-
} else if (status === 404) {
897
-
// Custom 404 page from _redirects (wins over settings.custom404)
898
-
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
899
-
const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath, settings);
900
-
// Override status to 404
901
-
return new Response(response.body, {
902
-
status: 404,
903
-
headers: response.headers,
904
-
});
905
-
}
906
-
}
907
-
}
908
-
909
-
// No redirect matched, serve normally with settings
910
-
return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
911
-
}
912
-
913
-
// Internal function to serve a file with rewriting
914
-
async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string, settings: WispSettings | null = null) {
915
-
// Check if site is currently being cached - if so, return updating response
916
-
if (isSiteBeingCached(did, rkey)) {
917
-
return siteUpdatingResponse();
918
-
}
919
-
920
-
const indexFiles = getIndexFiles(settings);
921
-
922
-
// Normalize the request path (keep empty for root, remove trailing slash for others)
923
-
let requestPath = filePath || '';
924
-
if (requestPath.endsWith('/') && requestPath.length > 1) {
925
-
requestPath = requestPath.slice(0, -1);
926
-
}
927
-
928
-
// Check if this path is a directory first
929
-
const directoryPath = getCachedFilePath(did, rkey, requestPath);
930
-
if (await fileExists(directoryPath)) {
931
-
const { stat, readdir } = await import('fs/promises');
932
-
try {
933
-
const stats = await stat(directoryPath);
934
-
if (stats.isDirectory()) {
935
-
// It's a directory, try each index file in order
936
-
for (const indexFile of indexFiles) {
937
-
const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile;
938
-
const indexFilePath = getCachedFilePath(did, rkey, indexPath);
939
-
if (await fileExists(indexFilePath)) {
940
-
return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
941
-
}
942
-
}
943
-
// No index file found - check if directory listing is enabled
944
-
if (settings?.directoryListing) {
945
-
const { stat } = await import('fs/promises');
946
-
const entries = await readdir(directoryPath);
947
-
// Filter out .meta files and other hidden files
948
-
const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json');
949
-
950
-
// Check which entries are directories
951
-
const entriesWithType = await Promise.all(
952
-
visibleEntries.map(async (name) => {
953
-
try {
954
-
const entryPath = `${directoryPath}/${name}`;
955
-
const stats = await stat(entryPath);
956
-
return { name, isDirectory: stats.isDirectory() };
957
-
} catch {
958
-
return { name, isDirectory: false };
959
-
}
960
-
})
961
-
);
962
-
963
-
const html = generateDirectoryListing(requestPath, entriesWithType);
964
-
return new Response(html, {
965
-
headers: {
966
-
'Content-Type': 'text/html; charset=utf-8',
967
-
'Cache-Control': 'public, max-age=300',
968
-
},
969
-
});
970
-
}
971
-
// Fall through to 404/SPA handling
972
-
}
973
-
} catch (err) {
974
-
// If stat fails, continue with normal flow
975
-
}
976
-
}
977
-
978
-
// Not a directory, try to serve as a file
979
-
const fileRequestPath = requestPath || indexFiles[0];
980
-
const cacheKey = getCacheKey(did, rkey, fileRequestPath);
981
-
const cachedFile = getCachedFilePath(did, rkey, fileRequestPath);
982
-
983
-
// Check for rewritten HTML in cache first (if it's HTML)
984
-
const mimeTypeGuess = lookup(fileRequestPath) || 'application/octet-stream';
985
-
if (isHtmlContent(fileRequestPath, mimeTypeGuess)) {
986
-
const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
987
-
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
988
-
if (rewrittenContent) {
989
-
const headers: Record<string, string> = {
990
-
'Content-Type': 'text/html; charset=utf-8',
991
-
'Content-Encoding': 'gzip',
992
-
'Cache-Control': 'public, max-age=300',
993
-
};
994
-
applyCustomHeaders(headers, fileRequestPath, settings);
995
-
return new Response(rewrittenContent, { headers });
996
-
}
997
-
}
998
-
999
-
// Check in-memory file cache
1000
-
let content = fileCache.get(cacheKey);
1001
-
let meta = metadataCache.get(cacheKey);
1002
-
1003
-
if (!content && await fileExists(cachedFile)) {
1004
-
// Read from disk and cache
1005
-
content = await readFile(cachedFile);
1006
-
fileCache.set(cacheKey, content, content.length);
1007
-
1008
-
const metaFile = `${cachedFile}.meta`;
1009
-
if (await fileExists(metaFile)) {
1010
-
const metaJson = await readFile(metaFile, 'utf-8');
1011
-
meta = JSON.parse(metaJson);
1012
-
metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length);
1013
-
}
1014
-
}
1015
-
1016
-
if (content) {
1017
-
const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream';
1018
-
const isGzipped = meta?.encoding === 'gzip';
1019
-
1020
-
// Check if this is HTML content that needs rewriting
1021
-
if (isHtmlContent(fileRequestPath, mimeType)) {
1022
-
let htmlContent: string;
1023
-
if (isGzipped) {
1024
-
// Verify content is actually gzipped
1025
-
const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
1026
-
if (hasGzipMagic) {
1027
-
const { gunzipSync } = await import('zlib');
1028
-
htmlContent = gunzipSync(content).toString('utf-8');
1029
-
} else {
1030
-
console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
1031
-
htmlContent = content.toString('utf-8');
1032
-
}
1033
-
} else {
1034
-
htmlContent = content.toString('utf-8');
1035
-
}
1036
-
const rewritten = rewriteHtmlPaths(htmlContent, basePath, fileRequestPath);
1037
-
1038
-
// Recompress and cache the rewritten HTML
1039
-
const { gzipSync } = await import('zlib');
1040
-
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
1041
-
1042
-
const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
1043
-
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
1044
-
1045
-
const htmlHeaders: Record<string, string> = {
1046
-
'Content-Type': 'text/html; charset=utf-8',
1047
-
'Content-Encoding': 'gzip',
1048
-
'Cache-Control': 'public, max-age=300',
1049
-
};
1050
-
applyCustomHeaders(htmlHeaders, fileRequestPath, settings);
1051
-
return new Response(recompressed, { headers: htmlHeaders });
1052
-
}
1053
-
1054
-
// Non-HTML files: serve as-is
1055
-
const headers: Record<string, string> = {
1056
-
'Content-Type': mimeType,
1057
-
'Cache-Control': 'public, max-age=31536000, immutable',
1058
-
};
1059
-
1060
-
if (isGzipped) {
1061
-
const shouldServeCompressed = shouldCompressMimeType(mimeType);
1062
-
if (!shouldServeCompressed) {
1063
-
// Verify content is actually gzipped
1064
-
const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
1065
-
if (hasGzipMagic) {
1066
-
const { gunzipSync } = await import('zlib');
1067
-
const decompressed = gunzipSync(content);
1068
-
applyCustomHeaders(headers, fileRequestPath, settings);
1069
-
return new Response(decompressed, { headers });
1070
-
} else {
1071
-
console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
1072
-
applyCustomHeaders(headers, fileRequestPath, settings);
1073
-
return new Response(content, { headers });
1074
-
}
1075
-
}
1076
-
headers['Content-Encoding'] = 'gzip';
1077
-
}
1078
-
1079
-
applyCustomHeaders(headers, fileRequestPath, settings);
1080
-
return new Response(content, { headers });
1081
-
}
1082
-
1083
-
// Try index files for directory-like paths
1084
-
if (!fileRequestPath.includes('.')) {
1085
-
for (const indexFileName of indexFiles) {
1086
-
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
1087
-
const indexCacheKey = getCacheKey(did, rkey, indexPath);
1088
-
const indexFile = getCachedFilePath(did, rkey, indexPath);
1089
-
1090
-
// Check for rewritten index file in cache
1091
-
const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`);
1092
-
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
1093
-
if (rewrittenContent) {
1094
-
const headers: Record<string, string> = {
1095
-
'Content-Type': 'text/html; charset=utf-8',
1096
-
'Content-Encoding': 'gzip',
1097
-
'Cache-Control': 'public, max-age=300',
1098
-
};
1099
-
applyCustomHeaders(headers, indexPath, settings);
1100
-
return new Response(rewrittenContent, { headers });
1101
-
}
1102
-
1103
-
let indexContent = fileCache.get(indexCacheKey);
1104
-
let indexMeta = metadataCache.get(indexCacheKey);
1105
-
1106
-
if (!indexContent && await fileExists(indexFile)) {
1107
-
indexContent = await readFile(indexFile);
1108
-
fileCache.set(indexCacheKey, indexContent, indexContent.length);
1109
-
1110
-
const indexMetaFile = `${indexFile}.meta`;
1111
-
if (await fileExists(indexMetaFile)) {
1112
-
const metaJson = await readFile(indexMetaFile, 'utf-8');
1113
-
indexMeta = JSON.parse(metaJson);
1114
-
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
1115
-
}
1116
-
}
1117
-
1118
-
if (indexContent) {
1119
-
const isGzipped = indexMeta?.encoding === 'gzip';
1120
-
1121
-
let htmlContent: string;
1122
-
if (isGzipped) {
1123
-
// Verify content is actually gzipped
1124
-
const hasGzipMagic = indexContent.length >= 2 && indexContent[0] === 0x1f && indexContent[1] === 0x8b;
1125
-
if (hasGzipMagic) {
1126
-
const { gunzipSync } = await import('zlib');
1127
-
htmlContent = gunzipSync(indexContent).toString('utf-8');
1128
-
} else {
1129
-
console.warn(`Index file marked as gzipped but lacks magic bytes, serving as-is`);
1130
-
htmlContent = indexContent.toString('utf-8');
1131
-
}
1132
-
} else {
1133
-
htmlContent = indexContent.toString('utf-8');
1134
-
}
1135
-
const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath);
1136
-
1137
-
const { gzipSync } = await import('zlib');
1138
-
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
1139
-
1140
-
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
1141
-
1142
-
const headers: Record<string, string> = {
1143
-
'Content-Type': 'text/html; charset=utf-8',
1144
-
'Content-Encoding': 'gzip',
1145
-
'Cache-Control': 'public, max-age=300',
1146
-
};
1147
-
applyCustomHeaders(headers, indexPath, settings);
1148
-
return new Response(recompressed, { headers });
1149
-
}
1150
-
}
1151
-
}
1152
-
1153
-
// Try clean URLs: /about -> /about.html
1154
-
if (settings?.cleanUrls && !fileRequestPath.includes('.')) {
1155
-
const htmlPath = `${fileRequestPath}.html`;
1156
-
const htmlFile = getCachedFilePath(did, rkey, htmlPath);
1157
-
if (await fileExists(htmlFile)) {
1158
-
return serveFileInternalWithRewrite(did, rkey, htmlPath, basePath, settings);
1159
-
}
1160
-
1161
-
// Also try /about/index.html
1162
-
for (const indexFileName of indexFiles) {
1163
-
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
1164
-
const indexFile = getCachedFilePath(did, rkey, indexPath);
1165
-
if (await fileExists(indexFile)) {
1166
-
return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
1167
-
}
1168
-
}
1169
-
}
1170
-
1171
-
// SPA mode: serve SPA file for all non-existing routes
1172
-
if (settings?.spaMode) {
1173
-
const spaFile = settings.spaMode;
1174
-
const spaFilePath = getCachedFilePath(did, rkey, spaFile);
1175
-
if (await fileExists(spaFilePath)) {
1176
-
return serveFileInternalWithRewrite(did, rkey, spaFile, basePath, settings);
1177
-
}
1178
-
}
1179
-
1180
-
// Custom 404: serve custom 404 file if configured (wins conflict battle)
1181
-
if (settings?.custom404) {
1182
-
const custom404File = settings.custom404;
1183
-
const custom404Path = getCachedFilePath(did, rkey, custom404File);
1184
-
if (await fileExists(custom404Path)) {
1185
-
const response = await serveFileInternalWithRewrite(did, rkey, custom404File, basePath, settings);
1186
-
// Override status to 404
1187
-
return new Response(response.body, {
1188
-
status: 404,
1189
-
headers: response.headers,
1190
-
});
1191
-
}
1192
-
}
1193
-
1194
-
// Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html)
1195
-
const auto404Pages = ['404.html', 'not_found.html'];
1196
-
for (const auto404Page of auto404Pages) {
1197
-
const auto404Path = getCachedFilePath(did, rkey, auto404Page);
1198
-
if (await fileExists(auto404Path)) {
1199
-
const response = await serveFileInternalWithRewrite(did, rkey, auto404Page, basePath, settings);
1200
-
// Override status to 404
1201
-
return new Response(response.body, {
1202
-
status: 404,
1203
-
headers: response.headers,
1204
-
});
1205
-
}
1206
-
}
1207
-
1208
-
// Directory listing fallback: if enabled, show root directory listing on 404
1209
-
if (settings?.directoryListing) {
1210
-
const rootPath = getCachedFilePath(did, rkey, '');
1211
-
if (await fileExists(rootPath)) {
1212
-
const { stat, readdir } = await import('fs/promises');
1213
-
try {
1214
-
const stats = await stat(rootPath);
1215
-
if (stats.isDirectory()) {
1216
-
const entries = await readdir(rootPath);
1217
-
// Filter out .meta files and metadata
1218
-
const visibleEntries = entries.filter(entry =>
1219
-
!entry.endsWith('.meta') && entry !== '.metadata.json'
1220
-
);
1221
-
1222
-
// Check which entries are directories
1223
-
const entriesWithType = await Promise.all(
1224
-
visibleEntries.map(async (name) => {
1225
-
try {
1226
-
const entryPath = `${rootPath}/${name}`;
1227
-
const entryStats = await stat(entryPath);
1228
-
return { name, isDirectory: entryStats.isDirectory() };
1229
-
} catch {
1230
-
return { name, isDirectory: false };
1231
-
}
1232
-
})
1233
-
);
1234
-
1235
-
const html = generateDirectoryListing('', entriesWithType);
1236
-
return new Response(html, {
1237
-
status: 404,
1238
-
headers: {
1239
-
'Content-Type': 'text/html; charset=utf-8',
1240
-
'Cache-Control': 'public, max-age=300',
1241
-
},
1242
-
});
1243
-
}
1244
-
} catch (err) {
1245
-
// If directory listing fails, fall through to 404
1246
-
}
1247
-
}
1248
-
}
1249
-
1250
-
// Default styled 404 page
1251
-
const html = generate404Page();
1252
-
return new Response(html, {
1253
-
status: 404,
1254
-
headers: {
1255
-
'Content-Type': 'text/html; charset=utf-8',
1256
-
'Cache-Control': 'public, max-age=300',
1257
-
},
1258
-
});
1259
-
}
1260
-
1261
-
// Helper to ensure site is cached
1262
-
async function ensureSiteCached(did: string, rkey: string): Promise<boolean> {
1263
-
if (isCached(did, rkey)) {
1264
-
return true;
1265
-
}
1266
-
1267
-
// Fetch and cache the site
1268
-
const siteData = await fetchSiteRecord(did, rkey);
1269
-
if (!siteData) {
1270
-
logger.error('Site record not found', null, { did, rkey });
1271
-
return false;
1272
-
}
1273
-
1274
-
const pdsEndpoint = await getPdsForDid(did);
1275
-
if (!pdsEndpoint) {
1276
-
logger.error('PDS not found for DID', null, { did });
1277
-
return false;
1278
-
}
1279
-
1280
-
// Mark site as being cached to prevent serving stale content during update
1281
-
markSiteAsBeingCached(did, rkey);
1282
-
1283
-
try {
1284
-
await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid);
1285
-
// Clear redirect rules cache since the site was updated
1286
-
clearRedirectRulesCache(did, rkey);
1287
-
logger.info('Site cached successfully', { did, rkey });
1288
-
return true;
1289
-
} catch (err) {
1290
-
logger.error('Failed to cache site', err, { did, rkey });
1291
-
return false;
1292
-
} finally {
1293
-
// Always unmark, even if caching fails
1294
-
unmarkSiteAsBeingCached(did, rkey);
1295
-
}
1296
-
}
1297
-
1298
-
const app = new Hono();
1299
-
1300
-
// Add CORS middleware - allow all origins for static site hosting
1301
-
app.use('*', cors({
1302
-
origin: '*',
1303
-
allowMethods: ['GET', 'HEAD', 'OPTIONS'],
1304
-
allowHeaders: ['Content-Type', 'Authorization'],
1305
-
exposeHeaders: ['Content-Length', 'Content-Type', 'Content-Encoding', 'Cache-Control'],
1306
-
maxAge: 86400, // 24 hours
1307
-
credentials: false,
1308
-
}));
1309
-
1310
-
// Add observability middleware
1311
-
app.use('*', observabilityMiddleware('hosting-service'));
1312
-
1313
-
// Error handler
1314
-
app.onError(observabilityErrorHandler('hosting-service'));
1315
-
1316
-
// Main site serving route
1317
-
app.get('/*', async (c) => {
1318
-
const url = new URL(c.req.url);
1319
-
const hostname = c.req.header('host') || '';
1320
-
const rawPath = url.pathname.replace(/^\//, '');
1321
-
const path = sanitizePath(rawPath);
1322
-
1323
-
// Check if this is sites.wisp.place subdomain (strip port for comparison)
1324
-
const hostnameWithoutPort = hostname.split(':')[0];
1325
-
if (hostnameWithoutPort === `sites.${BASE_HOST}`) {
1326
-
// Sanitize the path FIRST to prevent path traversal
1327
-
const sanitizedFullPath = sanitizePath(rawPath);
1328
-
1329
-
// Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
1330
-
const pathParts = sanitizedFullPath.split('/');
1331
-
if (pathParts.length < 2) {
1332
-
return c.text('Invalid path format. Expected: /identifier/sitename/path', 400);
1333
-
}
1334
-
1335
-
const identifier = pathParts[0];
1336
-
const site = pathParts[1];
1337
-
const filePath = pathParts.slice(2).join('/');
1338
-
1339
-
// Additional validation: identifier must be a valid DID or handle format
1340
-
if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
1341
-
return c.text('Invalid identifier', 400);
1342
-
}
1343
-
1344
-
// Validate site parameter exists
1345
-
if (!site) {
1346
-
return c.text('Site name required', 400);
1347
-
}
1348
-
1349
-
// Validate site name (rkey)
1350
-
if (!isValidRkey(site)) {
1351
-
return c.text('Invalid site name', 400);
1352
-
}
1353
-
1354
-
// Resolve identifier to DID
1355
-
const did = await resolveDid(identifier);
1356
-
if (!did) {
1357
-
return c.text('Invalid identifier', 400);
1358
-
}
1359
-
1360
-
// Check if site is currently being cached - return updating response early
1361
-
if (isSiteBeingCached(did, site)) {
1362
-
return siteUpdatingResponse();
1363
-
}
1364
-
1365
-
// Ensure site is cached
1366
-
const cached = await ensureSiteCached(did, site);
1367
-
if (!cached) {
1368
-
return c.text('Site not found', 404);
1369
-
}
1370
-
1371
-
// Serve with HTML path rewriting to handle absolute paths
1372
-
const basePath = `/${identifier}/${site}/`;
1373
-
const headers: Record<string, string> = {};
1374
-
c.req.raw.headers.forEach((value, key) => {
1375
-
headers[key.toLowerCase()] = value;
1376
-
});
1377
-
return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers);
1378
-
}
1379
-
1380
-
// Check if this is a DNS hash subdomain
1381
-
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
1382
-
if (dnsMatch) {
1383
-
const hash = dnsMatch[1];
1384
-
const baseDomain = dnsMatch[2];
1385
-
1386
-
if (!hash) {
1387
-
return c.text('Invalid DNS hash', 400);
1388
-
}
1389
-
1390
-
if (baseDomain !== BASE_HOST) {
1391
-
return c.text('Invalid base domain', 400);
1392
-
}
1393
-
1394
-
const customDomain = await getCustomDomainByHash(hash);
1395
-
if (!customDomain) {
1396
-
return c.text('Custom domain not found or not verified', 404);
1397
-
}
1398
-
1399
-
if (!customDomain.rkey) {
1400
-
return c.text('Domain not mapped to a site', 404);
1401
-
}
1402
-
1403
-
const rkey = customDomain.rkey;
1404
-
if (!isValidRkey(rkey)) {
1405
-
return c.text('Invalid site configuration', 500);
1406
-
}
1407
-
1408
-
// Check if site is currently being cached - return updating response early
1409
-
if (isSiteBeingCached(customDomain.did, rkey)) {
1410
-
return siteUpdatingResponse();
1411
-
}
1412
-
1413
-
const cached = await ensureSiteCached(customDomain.did, rkey);
1414
-
if (!cached) {
1415
-
return c.text('Site not found', 404);
1416
-
}
1417
-
1418
-
const headers: Record<string, string> = {};
1419
-
c.req.raw.headers.forEach((value, key) => {
1420
-
headers[key.toLowerCase()] = value;
1421
-
});
1422
-
return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
1423
-
}
1424
-
1425
-
// Route 2: Registered subdomains - /*.wisp.place/*
1426
-
if (hostname.endsWith(`.${BASE_HOST}`)) {
1427
-
const domainInfo = await getWispDomain(hostname);
1428
-
if (!domainInfo) {
1429
-
return c.text('Subdomain not registered', 404);
1430
-
}
1431
-
1432
-
if (!domainInfo.rkey) {
1433
-
return c.text('Domain not mapped to a site', 404);
1434
-
}
1435
-
1436
-
const rkey = domainInfo.rkey;
1437
-
if (!isValidRkey(rkey)) {
1438
-
return c.text('Invalid site configuration', 500);
1439
-
}
1440
-
1441
-
// Check if site is currently being cached - return updating response early
1442
-
if (isSiteBeingCached(domainInfo.did, rkey)) {
1443
-
return siteUpdatingResponse();
1444
-
}
1445
-
1446
-
const cached = await ensureSiteCached(domainInfo.did, rkey);
1447
-
if (!cached) {
1448
-
return c.text('Site not found', 404);
1449
-
}
1450
-
1451
-
const headers: Record<string, string> = {};
1452
-
c.req.raw.headers.forEach((value, key) => {
1453
-
headers[key.toLowerCase()] = value;
1454
-
});
1455
-
return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers);
1456
-
}
1457
-
1458
-
// Route 1: Custom domains - /*
1459
-
const customDomain = await getCustomDomain(hostname);
1460
-
if (!customDomain) {
1461
-
return c.text('Custom domain not found or not verified', 404);
1462
-
}
1463
-
1464
-
if (!customDomain.rkey) {
1465
-
return c.text('Domain not mapped to a site', 404);
1466
-
}
1467
-
1468
-
const rkey = customDomain.rkey;
1469
-
if (!isValidRkey(rkey)) {
1470
-
return c.text('Invalid site configuration', 500);
1471
-
}
1472
-
1473
-
// Check if site is currently being cached - return updating response early
1474
-
if (isSiteBeingCached(customDomain.did, rkey)) {
1475
-
return siteUpdatingResponse();
1476
-
}
1477
-
1478
-
const cached = await ensureSiteCached(customDomain.did, rkey);
1479
-
if (!cached) {
1480
-
return c.text('Site not found', 404);
1481
-
}
1482
-
1483
-
const headers: Record<string, string> = {};
1484
-
c.req.raw.headers.forEach((value, key) => {
1485
-
headers[key.toLowerCase()] = value;
1486
-
});
1487
-
return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
1488
-
});
1489
-
1490
-
// Internal observability endpoints (for admin panel)
1491
-
app.get('/__internal__/observability/logs', (c) => {
1492
-
const query = c.req.query();
1493
-
const filter: any = {};
1494
-
if (query.level) filter.level = query.level;
1495
-
if (query.service) filter.service = query.service;
1496
-
if (query.search) filter.search = query.search;
1497
-
if (query.eventType) filter.eventType = query.eventType;
1498
-
if (query.limit) filter.limit = parseInt(query.limit as string);
1499
-
return c.json({ logs: logCollector.getLogs(filter) });
1500
-
});
1501
-
1502
-
app.get('/__internal__/observability/errors', (c) => {
1503
-
const query = c.req.query();
1504
-
const filter: any = {};
1505
-
if (query.service) filter.service = query.service;
1506
-
if (query.limit) filter.limit = parseInt(query.limit as string);
1507
-
return c.json({ errors: errorTracker.getErrors(filter) });
1508
-
});
1509
-
1510
-
app.get('/__internal__/observability/metrics', (c) => {
1511
-
const query = c.req.query();
1512
-
const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
1513
-
const stats = metricsCollector.getStats('hosting-service', timeWindow);
1514
-
return c.json({ stats, timeWindow });
1515
-
});
1516
-
1517
-
app.get('/__internal__/observability/cache', async (c) => {
1518
-
const { getCacheStats } = await import('./lib/cache');
1519
-
const stats = getCacheStats();
1520
-
return c.json({ cache: stats });
1521
-
});
1522
-
1523
-
export default app;
+6
hosting-service/tsconfig.json
apps/hosting-service/tsconfig.json
+6
hosting-service/tsconfig.json
apps/hosting-service/tsconfig.json
lexicons/fs.json
packages/@wisp/lexicons/lexicons/fs.json
lexicons/fs.json
packages/@wisp/lexicons/lexicons/fs.json
lexicons/settings.json
packages/@wisp/lexicons/lexicons/settings.json
lexicons/settings.json
packages/@wisp/lexicons/lexicons/settings.json
lexicons/subfs.json
packages/@wisp/lexicons/lexicons/subfs.json
lexicons/subfs.json
packages/@wisp/lexicons/lexicons/subfs.json
+15
-55
package.json
+15
-55
package.json
···
1
1
{
2
-
"name": "elysia-static",
2
+
"name": "@wisp/monorepo",
3
3
"version": "1.0.50",
4
+
"private": true,
5
+
"workspaces": [
6
+
"packages/@wisp/*",
7
+
"apps/main-app",
8
+
"apps/hosting-service"
9
+
],
4
10
"scripts": {
5
11
"test": "bun test",
6
-
"dev": "bun run --watch src/index.ts",
7
-
"start": "bun run src/index.ts",
8
-
"build": "bun build --compile --target bun --outfile server src/index.ts",
9
-
"screenshot": "bun run scripts/screenshot-sites.ts"
10
-
},
11
-
"dependencies": {
12
-
"@atproto/api": "^0.17.3",
13
-
"@atproto/lex-cli": "^0.9.5",
14
-
"@atproto/oauth-client-node": "^0.3.9",
15
-
"@atproto/xrpc-server": "^0.9.5",
16
-
"@elysiajs/cors": "^1.4.0",
17
-
"@elysiajs/eden": "^1.4.3",
18
-
"@elysiajs/openapi": "^1.4.11",
19
-
"@elysiajs/opentelemetry": "^1.4.6",
20
-
"@elysiajs/static": "^1.4.2",
21
-
"@radix-ui/react-checkbox": "^1.3.3",
22
-
"@radix-ui/react-dialog": "^1.1.15",
23
-
"@radix-ui/react-label": "^2.1.7",
24
-
"@radix-ui/react-radio-group": "^1.3.8",
25
-
"@radix-ui/react-slot": "^1.2.3",
26
-
"@radix-ui/react-tabs": "^1.1.13",
27
-
"@tanstack/react-query": "^5.90.2",
28
-
"actor-typeahead": "^0.1.1",
29
-
"atproto-ui": "^0.11.3",
30
-
"class-variance-authority": "^0.7.1",
31
-
"clsx": "^2.1.1",
32
-
"elysia": "latest",
33
-
"iron-session": "^8.0.4",
34
-
"lucide-react": "^0.546.0",
35
-
"multiformats": "^13.4.1",
36
-
"prismjs": "^1.30.0",
37
-
"react": "^19.2.0",
38
-
"react-dom": "^19.2.0",
39
-
"tailwind-merge": "^3.3.1",
40
-
"tailwindcss": "4",
41
-
"tw-animate-css": "^1.4.0",
42
-
"typescript": "^5.9.3",
43
-
"zlib": "^1.0.5"
44
-
},
45
-
"devDependencies": {
46
-
"@types/react": "^19.2.2",
47
-
"@types/react-dom": "^19.2.1",
48
-
"bun-plugin-tailwind": "^0.1.2",
49
-
"bun-types": "latest",
50
-
"esbuild": "0.26.0",
51
-
"playwright": "^1.49.0"
52
-
},
53
-
"module": "src/index.js",
54
-
"trustedDependencies": [
55
-
"bun",
56
-
"cbor-extract",
57
-
"core-js",
58
-
"protobufjs"
59
-
]
12
+
"dev": "bun run --watch apps/main-app/src/index.ts",
13
+
"start": "bun run apps/main-app/src/index.ts",
14
+
"build": "bun build --compile --target bun --outfile server apps/main-app/src/index.ts",
15
+
"screenshot": "bun run apps/main-app/scripts/screenshot-sites.ts",
16
+
"hosting:dev": "cd apps/hosting-service && npm run dev",
17
+
"hosting:build": "cd apps/hosting-service && npm run build",
18
+
"hosting:start": "cd apps/hosting-service && npm run start"
19
+
}
60
20
}
+31
packages/@wisp/atproto-utils/package.json
+31
packages/@wisp/atproto-utils/package.json
···
1
+
{
2
+
"name": "@wisp/atproto-utils",
3
+
"version": "1.0.0",
4
+
"private": true,
5
+
"type": "module",
6
+
"main": "./src/index.ts",
7
+
"types": "./src/index.ts",
8
+
"exports": {
9
+
".": {
10
+
"types": "./src/index.ts",
11
+
"default": "./src/index.ts"
12
+
},
13
+
"./blob": {
14
+
"types": "./src/blob.ts",
15
+
"default": "./src/blob.ts"
16
+
},
17
+
"./compression": {
18
+
"types": "./src/compression.ts",
19
+
"default": "./src/compression.ts"
20
+
},
21
+
"./subfs": {
22
+
"types": "./src/subfs.ts",
23
+
"default": "./src/subfs.ts"
24
+
}
25
+
},
26
+
"dependencies": {
27
+
"@atproto/api": "^0.14.1",
28
+
"@wisp/lexicons": "workspace:*",
29
+
"multiformats": "^13.3.1"
30
+
}
31
+
}
+108
packages/@wisp/atproto-utils/src/blob.ts
+108
packages/@wisp/atproto-utils/src/blob.ts
···
1
+
import type { BlobRef } from "@atproto/lexicon";
2
+
import type { Directory, File } from "@wisp/lexicons/types/place/wisp/fs";
3
+
import { CID } from 'multiformats/cid';
4
+
import { sha256 } from 'multiformats/hashes/sha2';
5
+
import * as raw from 'multiformats/codecs/raw';
6
+
import { createHash } from 'crypto';
7
+
import * as mf from 'multiformats';
8
+
9
+
/**
10
+
* Compute CID (Content Identifier) for blob content
11
+
* Uses the same algorithm as AT Protocol: CIDv1 with raw codec and SHA-256
12
+
* Based on @atproto/common/src/ipld.ts sha256RawToCid implementation
13
+
*/
14
+
export function computeCID(content: Buffer): string {
15
+
// Use node crypto to compute sha256 hash (same as AT Protocol)
16
+
const hash = createHash('sha256').update(content).digest();
17
+
// Create digest object from hash bytes
18
+
const digest = mf.digest.create(sha256.code, hash);
19
+
// Create CIDv1 with raw codec
20
+
const cid = CID.createV1(raw.code, digest);
21
+
return cid.toString();
22
+
}
23
+
24
+
/**
25
+
* Extract blob information from a directory tree
26
+
* Returns a map of file paths to their blob refs and CIDs
27
+
*/
28
+
export function extractBlobMap(
29
+
directory: Directory,
30
+
currentPath: string = ''
31
+
): Map<string, { blobRef: BlobRef; cid: string }> {
32
+
const blobMap = new Map<string, { blobRef: BlobRef; cid: string }>();
33
+
34
+
for (const entry of directory.entries) {
35
+
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
36
+
37
+
if ('type' in entry.node && entry.node.type === 'file') {
38
+
const fileNode = entry.node as File;
39
+
// AT Protocol SDK returns BlobRef class instances, not plain objects
40
+
// The ref is a CID instance that can be converted to string
41
+
if (fileNode.blob && fileNode.blob.ref) {
42
+
const cidString = fileNode.blob.ref.toString();
43
+
blobMap.set(fullPath, {
44
+
blobRef: fileNode.blob,
45
+
cid: cidString
46
+
});
47
+
}
48
+
} else if ('type' in entry.node && entry.node.type === 'directory') {
49
+
const subMap = extractBlobMap(entry.node as Directory, fullPath);
50
+
subMap.forEach((value, key) => blobMap.set(key, value));
51
+
}
52
+
// Skip subfs nodes - they don't contain blobs in the main tree
53
+
}
54
+
55
+
return blobMap;
56
+
}
57
+
58
+
interface IpldLink {
59
+
$link: string;
60
+
}
61
+
62
+
interface TypedBlobRef {
63
+
ref: CID | IpldLink;
64
+
}
65
+
66
+
interface UntypedBlobRef {
67
+
cid: string;
68
+
}
69
+
70
+
function isIpldLink(obj: unknown): obj is IpldLink {
71
+
return typeof obj === 'object' && obj !== null && '$link' in obj && typeof (obj as IpldLink).$link === 'string';
72
+
}
73
+
74
+
function isTypedBlobRef(obj: unknown): obj is TypedBlobRef {
75
+
return typeof obj === 'object' && obj !== null && 'ref' in obj;
76
+
}
77
+
78
+
function isUntypedBlobRef(obj: unknown): obj is UntypedBlobRef {
79
+
return typeof obj === 'object' && obj !== null && 'cid' in obj && typeof (obj as UntypedBlobRef).cid === 'string';
80
+
}
81
+
82
+
/**
83
+
* Extract CID from a blob reference (handles multiple blob ref formats)
84
+
*/
85
+
export function extractBlobCid(blobRef: unknown): string | null {
86
+
if (isIpldLink(blobRef)) {
87
+
return blobRef.$link;
88
+
}
89
+
90
+
if (isTypedBlobRef(blobRef)) {
91
+
const ref = blobRef.ref;
92
+
93
+
const cid = CID.asCID(ref);
94
+
if (cid) {
95
+
return cid.toString();
96
+
}
97
+
98
+
if (isIpldLink(ref)) {
99
+
return ref.$link;
100
+
}
101
+
}
102
+
103
+
if (isUntypedBlobRef(blobRef)) {
104
+
return blobRef.cid;
105
+
}
106
+
107
+
return null;
108
+
}
+95
packages/@wisp/atproto-utils/src/compression.ts
+95
packages/@wisp/atproto-utils/src/compression.ts
···
1
+
import { gzipSync } from 'zlib';
2
+
3
+
/**
4
+
* Determine if a file should be gzip compressed based on its MIME type and filename
5
+
*/
6
+
export function shouldCompressFile(mimeType: string, fileName?: string): boolean {
7
+
// Never compress _redirects file - it needs to be plain text for the hosting service
8
+
if (fileName && (fileName.endsWith('/_redirects') || fileName === '_redirects')) {
9
+
return false;
10
+
}
11
+
12
+
// Compress text-based files and uncompressed audio formats
13
+
const compressibleTypes = [
14
+
'text/html',
15
+
'text/css',
16
+
'text/javascript',
17
+
'application/javascript',
18
+
'application/json',
19
+
'image/svg+xml',
20
+
'text/xml',
21
+
'application/xml',
22
+
'text/plain',
23
+
'application/x-javascript',
24
+
// Uncompressed audio formats (WAV, AIFF, etc.)
25
+
'audio/wav',
26
+
'audio/wave',
27
+
'audio/x-wav',
28
+
'audio/aiff',
29
+
'audio/x-aiff'
30
+
];
31
+
32
+
// Check if mime type starts with any compressible type
33
+
return compressibleTypes.some(type => mimeType.startsWith(type));
34
+
}
35
+
36
+
/**
37
+
* Determines if a MIME type should benefit from gzip compression.
38
+
* Returns true for text-based web assets (HTML, CSS, JS, JSON, XML, SVG).
39
+
* Returns false for already-compressed formats (images, video, audio, PDFs).
40
+
*/
41
+
export function shouldCompressMimeType(mimeType: string | undefined): boolean {
42
+
if (!mimeType) return false;
43
+
44
+
const mime = mimeType.toLowerCase();
45
+
46
+
// Text-based web assets and uncompressed audio that benefit from compression
47
+
const compressibleTypes = [
48
+
'text/html',
49
+
'text/css',
50
+
'text/javascript',
51
+
'application/javascript',
52
+
'application/x-javascript',
53
+
'text/xml',
54
+
'application/xml',
55
+
'application/json',
56
+
'text/plain',
57
+
'image/svg+xml',
58
+
// Uncompressed audio formats
59
+
'audio/wav',
60
+
'audio/wave',
61
+
'audio/x-wav',
62
+
'audio/aiff',
63
+
'audio/x-aiff',
64
+
];
65
+
66
+
if (compressibleTypes.some(type => mime === type || mime.startsWith(type))) {
67
+
return true;
68
+
}
69
+
70
+
// Already-compressed formats that should NOT be double-compressed
71
+
const alreadyCompressedPrefixes = [
72
+
'video/',
73
+
'audio/',
74
+
'image/',
75
+
'application/pdf',
76
+
'application/zip',
77
+
'application/gzip',
78
+
];
79
+
80
+
if (alreadyCompressedPrefixes.some(prefix => mime.startsWith(prefix))) {
81
+
return false;
82
+
}
83
+
84
+
// Default to not compressing for unknown types
85
+
return false;
86
+
}
87
+
88
+
/**
89
+
* Compress a file using gzip with deterministic output
90
+
*/
91
+
export function compressFile(content: Buffer): Buffer {
92
+
return gzipSync(content, {
93
+
level: 9
94
+
});
95
+
}
+8
packages/@wisp/atproto-utils/src/index.ts
+8
packages/@wisp/atproto-utils/src/index.ts
···
1
+
// Blob utilities
2
+
export { computeCID, extractBlobMap, extractBlobCid } from './blob';
3
+
4
+
// Compression utilities
5
+
export { shouldCompressFile, shouldCompressMimeType, compressFile } from './compression';
6
+
7
+
// Subfs utilities
8
+
export { extractSubfsUris } from './subfs';
+31
packages/@wisp/atproto-utils/src/subfs.ts
+31
packages/@wisp/atproto-utils/src/subfs.ts
···
1
+
import type { Directory } from "@wisp/lexicons/types/place/wisp/fs";
2
+
3
+
/**
4
+
* Extract all subfs URIs from a directory tree with their mount paths
5
+
*/
6
+
export function extractSubfsUris(
7
+
directory: Directory,
8
+
currentPath: string = ''
9
+
): Array<{ uri: string; path: string }> {
10
+
const uris: Array<{ uri: string; path: string }> = [];
11
+
12
+
for (const entry of directory.entries) {
13
+
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
14
+
15
+
if ('type' in entry.node) {
16
+
if (entry.node.type === 'subfs') {
17
+
// Subfs node with subject URI
18
+
const subfsNode = entry.node as any;
19
+
if (subfsNode.subject) {
20
+
uris.push({ uri: subfsNode.subject, path: fullPath });
21
+
}
22
+
} else if (entry.node.type === 'directory') {
23
+
// Recursively search subdirectories
24
+
const subUris = extractSubfsUris(entry.node as Directory, fullPath);
25
+
uris.push(...subUris);
26
+
}
27
+
}
28
+
}
29
+
30
+
return uris;
31
+
}
+9
packages/@wisp/atproto-utils/tsconfig.json
+9
packages/@wisp/atproto-utils/tsconfig.json
+14
packages/@wisp/constants/package.json
+14
packages/@wisp/constants/package.json
+32
packages/@wisp/constants/src/index.ts
+32
packages/@wisp/constants/src/index.ts
···
1
+
/**
2
+
* Shared constants for wisp.place
3
+
*/
4
+
5
+
// Domain configuration
6
+
export const getBaseHost = () => {
7
+
if (typeof Bun !== 'undefined') {
8
+
return Bun.env.BASE_DOMAIN || "wisp.place";
9
+
}
10
+
return process.env.BASE_DOMAIN || "wisp.place";
11
+
};
12
+
13
+
export const BASE_HOST = getBaseHost();
14
+
15
+
// File size limits
16
+
export const MAX_SITE_SIZE = 300 * 1024 * 1024; // 300MB
17
+
export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
18
+
export const MAX_FILE_COUNT = 1000;
19
+
20
+
// Cache configuration
21
+
export const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
22
+
23
+
// Fetch timeouts and limits
24
+
export const FETCH_TIMEOUT_MS = 30000; // 30 seconds
25
+
export const MAX_JSON_SIZE = 10 * 1024 * 1024; // 10MB
26
+
export const MAX_BLOB_SIZE = MAX_FILE_SIZE; // Use file size limit
27
+
28
+
// Directory limits (AT Protocol lexicon constraints)
29
+
export const MAX_ENTRIES_PER_DIRECTORY = 500;
30
+
31
+
// Compression settings
32
+
export const GZIP_COMPRESSION_LEVEL = 9;
+9
packages/@wisp/constants/tsconfig.json
+9
packages/@wisp/constants/tsconfig.json
+29
packages/@wisp/database/package.json
+29
packages/@wisp/database/package.json
···
1
+
{
2
+
"name": "@wisp/database",
3
+
"version": "1.0.0",
4
+
"private": true,
5
+
"type": "module",
6
+
"main": "./src/index.ts",
7
+
"types": "./src/index.ts",
8
+
"exports": {
9
+
".": {
10
+
"types": "./src/index.ts",
11
+
"default": "./src/index.ts"
12
+
},
13
+
"./types": {
14
+
"types": "./src/types.ts",
15
+
"default": "./src/types.ts"
16
+
}
17
+
},
18
+
"dependencies": {
19
+
"postgres": "^3.4.5"
20
+
},
21
+
"peerDependencies": {
22
+
"bun": "^1.0.0"
23
+
},
24
+
"peerDependenciesMeta": {
25
+
"bun": {
26
+
"optional": true
27
+
}
28
+
}
29
+
}
+22
packages/@wisp/database/src/index.ts
+22
packages/@wisp/database/src/index.ts
···
1
+
/**
2
+
* Shared database utilities for wisp.place
3
+
*
4
+
* This package provides database query functions that work across both
5
+
* main-app (Bun SQL) and hosting-service (postgres) environments.
6
+
*
7
+
* The actual database client is passed in by the consuming application.
8
+
*/
9
+
10
+
export * from './types';
11
+
12
+
// Re-export types
13
+
export type {
14
+
DomainLookup,
15
+
CustomDomainLookup,
16
+
SiteRecord,
17
+
OAuthState,
18
+
OAuthSession,
19
+
OAuthKey,
20
+
CookieSecret,
21
+
AdminUser
22
+
} from './types';
+56
packages/@wisp/database/src/types.ts
+56
packages/@wisp/database/src/types.ts
···
1
+
/**
2
+
* Shared database types used across main-app and hosting-service
3
+
*/
4
+
5
+
export interface DomainLookup {
6
+
did: string;
7
+
rkey: string | null;
8
+
}
9
+
10
+
export interface CustomDomainLookup {
11
+
id: string;
12
+
domain: string;
13
+
did: string;
14
+
rkey: string | null;
15
+
verified: boolean;
16
+
}
17
+
18
+
export interface SiteRecord {
19
+
did: string;
20
+
rkey: string;
21
+
display_name?: string;
22
+
created_at?: number;
23
+
updated_at?: number;
24
+
}
25
+
26
+
export interface OAuthState {
27
+
key: string;
28
+
data: string;
29
+
created_at?: number;
30
+
expires_at?: number;
31
+
}
32
+
33
+
export interface OAuthSession {
34
+
sub: string;
35
+
data: string;
36
+
updated_at?: number;
37
+
expires_at?: number;
38
+
}
39
+
40
+
export interface OAuthKey {
41
+
kid: string;
42
+
jwk: string;
43
+
created_at?: number;
44
+
}
45
+
46
+
export interface CookieSecret {
47
+
id: string;
48
+
secret: string;
49
+
created_at?: number;
50
+
}
51
+
52
+
export interface AdminUser {
53
+
username: string;
54
+
password_hash: string;
55
+
created_at?: number;
56
+
}
+9
packages/@wisp/database/tsconfig.json
+9
packages/@wisp/database/tsconfig.json
+34
packages/@wisp/fs-utils/package.json
+34
packages/@wisp/fs-utils/package.json
···
1
+
{
2
+
"name": "@wisp/fs-utils",
3
+
"version": "1.0.0",
4
+
"private": true,
5
+
"type": "module",
6
+
"main": "./src/index.ts",
7
+
"types": "./src/index.ts",
8
+
"exports": {
9
+
".": {
10
+
"types": "./src/index.ts",
11
+
"default": "./src/index.ts"
12
+
},
13
+
"./path": {
14
+
"types": "./src/path.ts",
15
+
"default": "./src/path.ts"
16
+
},
17
+
"./tree": {
18
+
"types": "./src/tree.ts",
19
+
"default": "./src/tree.ts"
20
+
},
21
+
"./manifest": {
22
+
"types": "./src/manifest.ts",
23
+
"default": "./src/manifest.ts"
24
+
},
25
+
"./subfs-split": {
26
+
"types": "./src/subfs-split.ts",
27
+
"default": "./src/subfs-split.ts"
28
+
}
29
+
},
30
+
"dependencies": {
31
+
"@atproto/api": "^0.14.1",
32
+
"@wisp/lexicons": "workspace:*"
33
+
}
34
+
}
+12
packages/@wisp/fs-utils/src/index.ts
+12
packages/@wisp/fs-utils/src/index.ts
···
1
+
// Path utilities
2
+
export { sanitizePath, normalizePath } from './path';
3
+
4
+
// Tree processing
5
+
export type { UploadedFile, FileUploadResult, ProcessedDirectory } from './tree';
6
+
export { processUploadedFiles, updateFileBlobs, countFilesInDirectory, collectFileCidsFromEntries } from './tree';
7
+
8
+
// Manifest creation
9
+
export { createManifest } from './manifest';
10
+
11
+
// Subfs splitting utilities
12
+
export { estimateDirectorySize, findLargeDirectories, replaceDirectoryWithSubfs } from './subfs-split';
+27
packages/@wisp/fs-utils/src/manifest.ts
+27
packages/@wisp/fs-utils/src/manifest.ts
···
1
+
import type { Record, Directory } from "@wisp/lexicons/types/place/wisp/fs";
2
+
import { validateRecord } from "@wisp/lexicons/types/place/wisp/fs";
3
+
4
+
/**
5
+
* Create the manifest record for a site
6
+
*/
7
+
export function createManifest(
8
+
siteName: string,
9
+
root: Directory,
10
+
fileCount: number
11
+
): Record {
12
+
const manifest = {
13
+
$type: 'place.wisp.fs' as const,
14
+
site: siteName,
15
+
root,
16
+
fileCount,
17
+
createdAt: new Date().toISOString()
18
+
};
19
+
20
+
// Validate the manifest before returning
21
+
const validationResult = validateRecord(manifest);
22
+
if (!validationResult.success) {
23
+
throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`);
24
+
}
25
+
26
+
return manifest;
27
+
}
+29
packages/@wisp/fs-utils/src/path.ts
+29
packages/@wisp/fs-utils/src/path.ts
···
1
+
/**
2
+
* Sanitize a file path to prevent directory traversal attacks
3
+
* Removes any path segments that attempt to go up directories
4
+
*/
5
+
export function sanitizePath(filePath: string): string {
6
+
// Remove leading slashes
7
+
let cleaned = filePath.replace(/^\/+/, '');
8
+
9
+
// Split into segments and filter out dangerous ones
10
+
const segments = cleaned.split('/').filter(segment => {
11
+
// Remove empty segments
12
+
if (!segment || segment === '.') return false;
13
+
// Remove parent directory references
14
+
if (segment === '..') return false;
15
+
// Remove segments with null bytes
16
+
if (segment.includes('\0')) return false;
17
+
return true;
18
+
});
19
+
20
+
// Rejoin the safe segments
21
+
return segments.join('/');
22
+
}
23
+
24
+
/**
25
+
* Normalize a path by removing leading base folder names
26
+
*/
27
+
export function normalizePath(path: string): string {
28
+
return path.replace(/^[^\/]*\//, '');
29
+
}
+113
packages/@wisp/fs-utils/src/subfs-split.ts
+113
packages/@wisp/fs-utils/src/subfs-split.ts
···
1
+
import type { Directory } from "@wisp/lexicons/types/place/wisp/fs";
2
+
3
+
/**
4
+
* Estimate the JSON size of a directory tree
5
+
*/
6
+
export function estimateDirectorySize(directory: Directory): number {
7
+
return JSON.stringify(directory).length;
8
+
}
9
+
10
+
/**
11
+
* Count files in a directory tree
12
+
*/
13
+
export function countFilesInDirectory(directory: Directory): number {
14
+
let count = 0;
15
+
for (const entry of directory.entries) {
16
+
if ('type' in entry.node && entry.node.type === 'file') {
17
+
count++;
18
+
} else if ('type' in entry.node && entry.node.type === 'directory') {
19
+
count += countFilesInDirectory(entry.node as Directory);
20
+
}
21
+
}
22
+
return count;
23
+
}
24
+
25
+
/**
26
+
* Find all directories in a tree with their paths and sizes
27
+
*/
28
+
export function findLargeDirectories(directory: Directory, currentPath: string = ''): Array<{
29
+
path: string;
30
+
directory: Directory;
31
+
size: number;
32
+
fileCount: number;
33
+
}> {
34
+
const result: Array<{ path: string; directory: Directory; size: number; fileCount: number }> = [];
35
+
36
+
for (const entry of directory.entries) {
37
+
if ('type' in entry.node && entry.node.type === 'directory') {
38
+
const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
39
+
const dir = entry.node as Directory;
40
+
const size = estimateDirectorySize(dir);
41
+
const fileCount = countFilesInDirectory(dir);
42
+
43
+
result.push({ path: dirPath, directory: dir, size, fileCount });
44
+
45
+
// Recursively find subdirectories
46
+
const subdirs = findLargeDirectories(dir, dirPath);
47
+
result.push(...subdirs);
48
+
}
49
+
}
50
+
51
+
return result;
52
+
}
53
+
54
+
/**
55
+
* Replace a directory with a subfs node in the tree
56
+
*/
57
+
export function replaceDirectoryWithSubfs(
58
+
directory: Directory,
59
+
targetPath: string,
60
+
subfsUri: string
61
+
): Directory {
62
+
const pathParts = targetPath.split('/');
63
+
const targetName = pathParts[pathParts.length - 1];
64
+
const parentPath = pathParts.slice(0, -1).join('/');
65
+
66
+
// If this is a root-level directory
67
+
if (pathParts.length === 1) {
68
+
const newEntries = directory.entries.map(entry => {
69
+
if (entry.name === targetName && 'type' in entry.node && entry.node.type === 'directory') {
70
+
return {
71
+
name: entry.name,
72
+
node: {
73
+
$type: 'place.wisp.fs#subfs' as const,
74
+
type: 'subfs' as const,
75
+
subject: subfsUri,
76
+
flat: false // Preserve directory structure
77
+
}
78
+
};
79
+
}
80
+
return entry;
81
+
});
82
+
83
+
return {
84
+
$type: 'place.wisp.fs#directory' as const,
85
+
type: 'directory' as const,
86
+
entries: newEntries
87
+
};
88
+
}
89
+
90
+
// Recursively navigate to parent directory
91
+
const newEntries = directory.entries.map(entry => {
92
+
if ('type' in entry.node && entry.node.type === 'directory') {
93
+
const entryPath = entry.name;
94
+
if (parentPath.startsWith(entryPath) || parentPath === entry.name) {
95
+
const remainingPath = pathParts.slice(1).join('/');
96
+
return {
97
+
name: entry.name,
98
+
node: {
99
+
...replaceDirectoryWithSubfs(entry.node as Directory, remainingPath, subfsUri),
100
+
$type: 'place.wisp.fs#directory' as const
101
+
}
102
+
};
103
+
}
104
+
}
105
+
return entry;
106
+
});
107
+
108
+
return {
109
+
$type: 'place.wisp.fs#directory' as const,
110
+
type: 'directory' as const,
111
+
entries: newEntries
112
+
};
113
+
}
+241
packages/@wisp/fs-utils/src/tree.ts
+241
packages/@wisp/fs-utils/src/tree.ts
···
1
+
import type { BlobRef } from "@atproto/api";
2
+
import type { Directory, Entry, File } from "@wisp/lexicons/types/place/wisp/fs";
3
+
4
+
export interface UploadedFile {
5
+
name: string;
6
+
content: Buffer;
7
+
mimeType: string;
8
+
size: number;
9
+
compressed?: boolean;
10
+
base64Encoded?: boolean;
11
+
originalMimeType?: string;
12
+
}
13
+
14
+
export interface FileUploadResult {
15
+
hash: string;
16
+
blobRef: BlobRef;
17
+
encoding?: 'gzip';
18
+
mimeType?: string;
19
+
base64?: boolean;
20
+
}
21
+
22
+
export interface ProcessedDirectory {
23
+
directory: Directory;
24
+
fileCount: number;
25
+
}
26
+
27
+
/**
28
+
* Process uploaded files into a directory structure
29
+
*/
30
+
export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory {
31
+
const entries: Entry[] = [];
32
+
let fileCount = 0;
33
+
34
+
// Group files by directory
35
+
const directoryMap = new Map<string, UploadedFile[]>();
36
+
37
+
for (const file of files) {
38
+
// Skip undefined/null files (defensive)
39
+
if (!file || !file.name) {
40
+
console.error('Skipping undefined or invalid file in processUploadedFiles');
41
+
continue;
42
+
}
43
+
44
+
// Remove any base folder name from the path
45
+
const normalizedPath = file.name.replace(/^[^\/]*\//, '');
46
+
47
+
// Skip files in .git directories
48
+
if (normalizedPath.startsWith('.git/') || normalizedPath === '.git') {
49
+
continue;
50
+
}
51
+
52
+
const parts = normalizedPath.split('/');
53
+
54
+
if (parts.length === 1) {
55
+
// Root level file
56
+
entries.push({
57
+
name: parts[0],
58
+
node: {
59
+
$type: 'place.wisp.fs#file' as const,
60
+
type: 'file' as const,
61
+
blob: undefined as any // Will be filled after upload
62
+
}
63
+
});
64
+
fileCount++;
65
+
} else {
66
+
// File in subdirectory
67
+
const dirPath = parts.slice(0, -1).join('/');
68
+
if (!directoryMap.has(dirPath)) {
69
+
directoryMap.set(dirPath, []);
70
+
}
71
+
directoryMap.get(dirPath)!.push({
72
+
...file,
73
+
name: normalizedPath
74
+
});
75
+
}
76
+
}
77
+
78
+
// Process subdirectories
79
+
for (const [dirPath, dirFiles] of directoryMap) {
80
+
const dirEntries: Entry[] = [];
81
+
82
+
for (const file of dirFiles) {
83
+
const fileName = file.name.split('/').pop()!;
84
+
dirEntries.push({
85
+
name: fileName,
86
+
node: {
87
+
$type: 'place.wisp.fs#file' as const,
88
+
type: 'file' as const,
89
+
blob: undefined as any // Will be filled after upload
90
+
}
91
+
});
92
+
fileCount++;
93
+
}
94
+
95
+
// Build nested directory structure
96
+
const pathParts = dirPath.split('/');
97
+
let currentEntries = entries;
98
+
99
+
for (let i = 0; i < pathParts.length; i++) {
100
+
const part = pathParts[i];
101
+
const isLast = i === pathParts.length - 1;
102
+
103
+
let existingEntry = currentEntries.find(e => e.name === part);
104
+
105
+
if (!existingEntry) {
106
+
const newDir = {
107
+
$type: 'place.wisp.fs#directory' as const,
108
+
type: 'directory' as const,
109
+
entries: isLast ? dirEntries : []
110
+
};
111
+
112
+
existingEntry = {
113
+
name: part,
114
+
node: newDir
115
+
};
116
+
currentEntries.push(existingEntry);
117
+
} else if ('entries' in existingEntry.node && isLast) {
118
+
(existingEntry.node as any).entries.push(...dirEntries);
119
+
}
120
+
121
+
if (existingEntry && 'entries' in existingEntry.node) {
122
+
currentEntries = (existingEntry.node as any).entries;
123
+
}
124
+
}
125
+
}
126
+
127
+
const result = {
128
+
directory: {
129
+
$type: 'place.wisp.fs#directory' as const,
130
+
type: 'directory' as const,
131
+
entries
132
+
},
133
+
fileCount
134
+
};
135
+
136
+
return result;
137
+
}
138
+
139
+
/**
140
+
* Update file blobs in directory structure after upload
141
+
* Uses path-based matching to correctly match files in nested directories
142
+
* Filters out files that were not successfully uploaded
143
+
*/
144
+
export function updateFileBlobs(
145
+
directory: Directory,
146
+
uploadResults: FileUploadResult[],
147
+
filePaths: string[],
148
+
currentPath: string = '',
149
+
successfulPaths?: Set<string>
150
+
): Directory {
151
+
const updatedEntries = directory.entries.map(entry => {
152
+
if ('type' in entry.node && entry.node.type === 'file') {
153
+
// Build the full path for this file
154
+
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
155
+
156
+
// If successfulPaths is provided, skip files that weren't successfully uploaded
157
+
if (successfulPaths && !successfulPaths.has(fullPath)) {
158
+
return null; // Filter out failed files
159
+
}
160
+
161
+
// Find exact match in filePaths (need to handle normalized paths)
162
+
const fileIndex = filePaths.findIndex((path) => {
163
+
// Normalize both paths by removing leading base folder
164
+
const normalizedUploadPath = path.replace(/^[^\/]*\//, '');
165
+
const normalizedEntryPath = fullPath;
166
+
return normalizedUploadPath === normalizedEntryPath || path === fullPath;
167
+
});
168
+
169
+
if (fileIndex !== -1 && uploadResults[fileIndex]) {
170
+
const result = uploadResults[fileIndex];
171
+
const blobRef = result.blobRef;
172
+
173
+
return {
174
+
...entry,
175
+
node: {
176
+
$type: 'place.wisp.fs#file' as const,
177
+
type: 'file' as const,
178
+
blob: blobRef,
179
+
...(result.encoding && { encoding: result.encoding }),
180
+
...(result.mimeType && { mimeType: result.mimeType }),
181
+
...(result.base64 && { base64: result.base64 })
182
+
}
183
+
};
184
+
} else {
185
+
console.error(`Could not find blob for file: ${fullPath}`);
186
+
return null; // Filter out files without blobs
187
+
}
188
+
} else if ('type' in entry.node && entry.node.type === 'directory') {
189
+
const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
190
+
return {
191
+
...entry,
192
+
node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths, dirPath, successfulPaths)
193
+
};
194
+
}
195
+
return entry;
196
+
}).filter(entry => entry !== null) as Entry[]; // Remove null entries (failed files)
197
+
198
+
const result = {
199
+
$type: 'place.wisp.fs#directory' as const,
200
+
type: 'directory' as const,
201
+
entries: updatedEntries
202
+
};
203
+
204
+
return result;
205
+
}
206
+
207
+
/**
208
+
* Count files in a directory tree
209
+
*/
210
+
export function countFilesInDirectory(directory: Directory): number {
211
+
let count = 0;
212
+
for (const entry of directory.entries) {
213
+
if ('type' in entry.node && entry.node.type === 'file') {
214
+
count++;
215
+
} else if ('type' in entry.node && entry.node.type === 'directory') {
216
+
count += countFilesInDirectory(entry.node as Directory);
217
+
}
218
+
}
219
+
return count;
220
+
}
221
+
222
+
/**
223
+
* Recursively collect file CIDs from entries for incremental update tracking
224
+
*/
225
+
export function collectFileCidsFromEntries(entries: Entry[], pathPrefix: string, fileCids: Record<string, string>): void {
226
+
for (const entry of entries) {
227
+
const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name;
228
+
const node = entry.node;
229
+
230
+
if ('type' in node && node.type === 'directory' && 'entries' in node) {
231
+
collectFileCidsFromEntries(node.entries, currentPath, fileCids);
232
+
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
233
+
const fileNode = node as File;
234
+
// Extract CID from blob ref
235
+
if (fileNode.blob && fileNode.blob.ref) {
236
+
const cid = fileNode.blob.ref.toString();
237
+
fileCids[currentPath] = cid;
238
+
}
239
+
}
240
+
}
241
+
}
+9
packages/@wisp/fs-utils/tsconfig.json
+9
packages/@wisp/fs-utils/tsconfig.json
+25
packages/@wisp/lexicons/README.md
+25
packages/@wisp/lexicons/README.md
···
1
+
# @wisp/lexicons
2
+
3
+
Shared AT Protocol lexicon definitions and generated TypeScript types for the wisp.place project.
4
+
5
+
## Contents
6
+
7
+
- `/lexicons` - Source lexicon JSON definitions
8
+
- `/src` - Generated TypeScript types and validation functions
9
+
10
+
## Usage
11
+
12
+
```typescript
13
+
import { ids, lexicons } from '@wisp/lexicons';
14
+
import type { PlaceWispFs } from '@wisp/lexicons/types/place/wisp/fs';
15
+
```
16
+
17
+
## Code Generation
18
+
19
+
To regenerate types from lexicon definitions:
20
+
21
+
```bash
22
+
npm run codegen
23
+
```
24
+
25
+
This uses `@atproto/lex-cli` to generate TypeScript types from the JSON schemas in `/lexicons`.
+44
packages/@wisp/lexicons/package.json
+44
packages/@wisp/lexicons/package.json
···
1
+
{
2
+
"name": "@wisp/lexicons",
3
+
"version": "1.0.0",
4
+
"private": true,
5
+
"type": "module",
6
+
"main": "./src/index.ts",
7
+
"types": "./src/index.ts",
8
+
"exports": {
9
+
".": {
10
+
"types": "./src/index.ts",
11
+
"default": "./src/index.ts"
12
+
},
13
+
"./types/place/wisp/fs": {
14
+
"types": "./src/types/place/wisp/fs.ts",
15
+
"default": "./src/types/place/wisp/fs.ts"
16
+
},
17
+
"./types/place/wisp/settings": {
18
+
"types": "./src/types/place/wisp/settings.ts",
19
+
"default": "./src/types/place/wisp/settings.ts"
20
+
},
21
+
"./types/place/wisp/subfs": {
22
+
"types": "./src/types/place/wisp/subfs.ts",
23
+
"default": "./src/types/place/wisp/subfs.ts"
24
+
},
25
+
"./lexicons": {
26
+
"types": "./src/lexicons.ts",
27
+
"default": "./src/lexicons.ts"
28
+
},
29
+
"./util": {
30
+
"types": "./src/util.ts",
31
+
"default": "./src/util.ts"
32
+
}
33
+
},
34
+
"scripts": {
35
+
"codegen": "lex gen-server ./src ./lexicons"
36
+
},
37
+
"dependencies": {
38
+
"@atproto/lexicon": "^0.5.1",
39
+
"@atproto/xrpc-server": "^0.9.5"
40
+
},
41
+
"devDependencies": {
42
+
"@atproto/lex-cli": "^0.9.5"
43
+
}
44
+
}
+11
packages/@wisp/lexicons/tsconfig.json
+11
packages/@wisp/lexicons/tsconfig.json
+34
packages/@wisp/observability/package.json
+34
packages/@wisp/observability/package.json
···
1
+
{
2
+
"name": "@wisp/observability",
3
+
"version": "1.0.0",
4
+
"private": true,
5
+
"type": "module",
6
+
"main": "./src/index.ts",
7
+
"types": "./src/index.ts",
8
+
"exports": {
9
+
".": {
10
+
"types": "./src/index.ts",
11
+
"default": "./src/index.ts"
12
+
},
13
+
"./core": {
14
+
"types": "./src/core.ts",
15
+
"default": "./src/core.ts"
16
+
},
17
+
"./middleware/elysia": {
18
+
"types": "./src/middleware/elysia.ts",
19
+
"default": "./src/middleware/elysia.ts"
20
+
},
21
+
"./middleware/hono": {
22
+
"types": "./src/middleware/hono.ts",
23
+
"default": "./src/middleware/hono.ts"
24
+
}
25
+
},
26
+
"peerDependencies": {
27
+
"hono": "^4.0.0"
28
+
},
29
+
"peerDependenciesMeta": {
30
+
"hono": {
31
+
"optional": true
32
+
}
33
+
}
34
+
}
+11
packages/@wisp/observability/src/index.ts
+11
packages/@wisp/observability/src/index.ts
···
1
+
/**
2
+
* @wisp/observability
3
+
* Framework-agnostic observability package with Elysia and Hono middleware
4
+
*/
5
+
6
+
// Export everything from core
7
+
export * from './core'
8
+
9
+
// Note: Middleware should be imported from specific subpaths:
10
+
// - import { observabilityMiddleware } from '@wisp/observability/middleware/elysia'
11
+
// - import { observabilityMiddleware, observabilityErrorHandler } from '@wisp/observability/middleware/hono'
+49
packages/@wisp/observability/src/middleware/elysia.ts
+49
packages/@wisp/observability/src/middleware/elysia.ts
···
1
+
import { metricsCollector, logCollector } from '../core'
2
+
3
+
/**
4
+
* Elysia middleware for observability
5
+
* Tracks request metrics and logs errors
6
+
*/
7
+
export function observabilityMiddleware(service: string) {
8
+
return {
9
+
beforeHandle: ({ request }: any) => {
10
+
// Store start time on request object
11
+
(request as any).__startTime = Date.now()
12
+
},
13
+
afterHandle: ({ request, set }: any) => {
14
+
const duration = Date.now() - ((request as any).__startTime || Date.now())
15
+
const url = new URL(request.url)
16
+
17
+
metricsCollector.recordRequest(
18
+
url.pathname,
19
+
request.method,
20
+
set.status || 200,
21
+
duration,
22
+
service
23
+
)
24
+
},
25
+
onError: ({ request, error, set }: any) => {
26
+
const duration = Date.now() - ((request as any).__startTime || Date.now())
27
+
const url = new URL(request.url)
28
+
29
+
metricsCollector.recordRequest(
30
+
url.pathname,
31
+
request.method,
32
+
set.status || 500,
33
+
duration,
34
+
service
35
+
)
36
+
37
+
// Don't log 404 errors
38
+
const statusCode = set.status || 500
39
+
if (statusCode !== 404) {
40
+
logCollector.error(
41
+
`Request failed: ${request.method} ${url.pathname}`,
42
+
service,
43
+
error,
44
+
{ statusCode }
45
+
)
46
+
}
47
+
}
48
+
}
49
+
}
+44
packages/@wisp/observability/src/middleware/hono.ts
+44
packages/@wisp/observability/src/middleware/hono.ts
···
1
+
import type { Context } from 'hono'
2
+
import { metricsCollector, logCollector } from '../core'
3
+
4
+
/**
5
+
* Hono middleware for observability
6
+
* Tracks request metrics
7
+
*/
8
+
export function observabilityMiddleware(service: string) {
9
+
return async (c: Context, next: () => Promise<void>) => {
10
+
const startTime = Date.now()
11
+
12
+
await next()
13
+
14
+
const duration = Date.now() - startTime
15
+
const { pathname } = new URL(c.req.url)
16
+
17
+
metricsCollector.recordRequest(
18
+
pathname,
19
+
c.req.method,
20
+
c.res.status,
21
+
duration,
22
+
service
23
+
)
24
+
}
25
+
}
26
+
27
+
/**
28
+
* Hono error handler for observability
29
+
* Logs errors with context
30
+
*/
31
+
export function observabilityErrorHandler(service: string) {
32
+
return (err: Error, c: Context) => {
33
+
const { pathname } = new URL(c.req.url)
34
+
35
+
logCollector.error(
36
+
`Request failed: ${c.req.method} ${pathname}`,
37
+
service,
38
+
err,
39
+
{ statusCode: c.res.status || 500 }
40
+
)
41
+
42
+
return c.text('Internal Server Error', 500)
43
+
}
44
+
}
+9
packages/@wisp/observability/tsconfig.json
+9
packages/@wisp/observability/tsconfig.json
+14
packages/@wisp/safe-fetch/package.json
+14
packages/@wisp/safe-fetch/package.json
+187
packages/@wisp/safe-fetch/src/index.ts
+187
packages/@wisp/safe-fetch/src/index.ts
···
1
+
/**
2
+
* SSRF-hardened fetch utility
3
+
* Prevents requests to private networks, localhost, and enforces timeouts/size limits
4
+
*/
5
+
6
+
const BLOCKED_IP_RANGES = [
7
+
/^127\./, // 127.0.0.0/8 - Loopback
8
+
/^10\./, // 10.0.0.0/8 - Private
9
+
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 - Private
10
+
/^192\.168\./, // 192.168.0.0/16 - Private
11
+
/^169\.254\./, // 169.254.0.0/16 - Link-local
12
+
/^::1$/, // IPv6 loopback
13
+
/^fe80:/, // IPv6 link-local
14
+
/^fc00:/, // IPv6 unique local
15
+
/^fd00:/, // IPv6 unique local
16
+
];
17
+
18
+
const BLOCKED_HOSTS = [
19
+
'localhost',
20
+
'metadata.google.internal',
21
+
'169.254.169.254',
22
+
];
23
+
24
+
const FETCH_TIMEOUT = 120000; // 120 seconds
25
+
const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads
26
+
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
27
+
const MAX_JSON_SIZE = 1024 * 1024; // 1MB
28
+
const MAX_BLOB_SIZE = 500 * 1024 * 1024; // 500MB
29
+
const MAX_REDIRECTS = 10;
30
+
31
+
function isBlockedHost(hostname: string): boolean {
32
+
const lowerHost = hostname.toLowerCase();
33
+
34
+
if (BLOCKED_HOSTS.includes(lowerHost)) {
35
+
return true;
36
+
}
37
+
38
+
for (const pattern of BLOCKED_IP_RANGES) {
39
+
if (pattern.test(lowerHost)) {
40
+
return true;
41
+
}
42
+
}
43
+
44
+
return false;
45
+
}
46
+
47
+
export async function safeFetch(
48
+
url: string,
49
+
options?: RequestInit & { maxSize?: number; timeout?: number }
50
+
): Promise<Response> {
51
+
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT;
52
+
const maxSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
53
+
54
+
// Parse and validate URL
55
+
let parsedUrl: URL;
56
+
try {
57
+
parsedUrl = new URL(url);
58
+
} catch (err) {
59
+
throw new Error(`Invalid URL: ${url}`);
60
+
}
61
+
62
+
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
63
+
throw new Error(`Blocked protocol: ${parsedUrl.protocol}`);
64
+
}
65
+
66
+
const hostname = parsedUrl.hostname;
67
+
if (isBlockedHost(hostname)) {
68
+
throw new Error(`Blocked host: ${hostname}`);
69
+
}
70
+
71
+
const controller = new AbortController();
72
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
73
+
74
+
try {
75
+
const response = await fetch(url, {
76
+
...options,
77
+
signal: controller.signal,
78
+
redirect: 'follow',
79
+
});
80
+
81
+
const contentLength = response.headers.get('content-length');
82
+
if (contentLength && parseInt(contentLength, 10) > maxSize) {
83
+
throw new Error(`Response too large: ${contentLength} bytes`);
84
+
}
85
+
86
+
return response;
87
+
} catch (err) {
88
+
if (err instanceof Error && err.name === 'AbortError') {
89
+
throw new Error(`Request timeout after ${timeoutMs}ms`);
90
+
}
91
+
throw err;
92
+
} finally {
93
+
clearTimeout(timeoutId);
94
+
}
95
+
}
96
+
97
+
export async function safeFetchJson<T = any>(
98
+
url: string,
99
+
options?: RequestInit & { maxSize?: number; timeout?: number }
100
+
): Promise<T> {
101
+
const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE;
102
+
const response = await safeFetch(url, { ...options, maxSize: maxJsonSize });
103
+
104
+
if (!response.ok) {
105
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
106
+
}
107
+
108
+
const reader = response.body?.getReader();
109
+
if (!reader) {
110
+
throw new Error('No response body');
111
+
}
112
+
113
+
const chunks: Uint8Array[] = [];
114
+
let totalSize = 0;
115
+
116
+
try {
117
+
while (true) {
118
+
const { done, value } = await reader.read();
119
+
if (done) break;
120
+
121
+
totalSize += value.length;
122
+
if (totalSize > maxJsonSize) {
123
+
throw new Error(`Response exceeds max size: ${maxJsonSize} bytes`);
124
+
}
125
+
126
+
chunks.push(value);
127
+
}
128
+
} finally {
129
+
reader.releaseLock();
130
+
}
131
+
132
+
const combined = new Uint8Array(totalSize);
133
+
let offset = 0;
134
+
for (const chunk of chunks) {
135
+
combined.set(chunk, offset);
136
+
offset += chunk.length;
137
+
}
138
+
139
+
const text = new TextDecoder().decode(combined);
140
+
return JSON.parse(text);
141
+
}
142
+
143
+
export async function safeFetchBlob(
144
+
url: string,
145
+
options?: RequestInit & { maxSize?: number; timeout?: number }
146
+
): Promise<Uint8Array> {
147
+
const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE;
148
+
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB;
149
+
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize, timeout: timeoutMs });
150
+
151
+
if (!response.ok) {
152
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
153
+
}
154
+
155
+
const reader = response.body?.getReader();
156
+
if (!reader) {
157
+
throw new Error('No response body');
158
+
}
159
+
160
+
const chunks: Uint8Array[] = [];
161
+
let totalSize = 0;
162
+
163
+
try {
164
+
while (true) {
165
+
const { done, value } = await reader.read();
166
+
if (done) break;
167
+
168
+
totalSize += value.length;
169
+
if (totalSize > maxBlobSize) {
170
+
throw new Error(`Blob exceeds max size: ${maxBlobSize} bytes`);
171
+
}
172
+
173
+
chunks.push(value);
174
+
}
175
+
} finally {
176
+
reader.releaseLock();
177
+
}
178
+
179
+
const combined = new Uint8Array(totalSize);
180
+
let offset = 0;
181
+
for (const chunk of chunks) {
182
+
combined.set(chunk, offset);
183
+
offset += chunk.length;
184
+
}
185
+
186
+
return combined;
187
+
}
+9
packages/@wisp/safe-fetch/tsconfig.json
+9
packages/@wisp/safe-fetch/tsconfig.json
public/acceptable-use/acceptable-use.tsx
apps/main-app/public/acceptable-use/acceptable-use.tsx
public/acceptable-use/acceptable-use.tsx
apps/main-app/public/acceptable-use/acceptable-use.tsx
public/acceptable-use/index.html
apps/main-app/public/acceptable-use/index.html
public/acceptable-use/index.html
apps/main-app/public/acceptable-use/index.html
public/admin/admin.tsx
apps/main-app/public/admin/admin.tsx
public/admin/admin.tsx
apps/main-app/public/admin/admin.tsx
public/admin/index.html
apps/main-app/public/admin/index.html
public/admin/index.html
apps/main-app/public/admin/index.html
public/admin/styles.css
apps/main-app/public/admin/styles.css
public/admin/styles.css
apps/main-app/public/admin/styles.css
public/android-chrome-192x192.png
apps/main-app/public/android-chrome-192x192.png
public/android-chrome-192x192.png
apps/main-app/public/android-chrome-192x192.png
public/android-chrome-512x512.png
apps/main-app/public/android-chrome-512x512.png
public/android-chrome-512x512.png
apps/main-app/public/android-chrome-512x512.png
public/apple-touch-icon.png
apps/main-app/public/apple-touch-icon.png
public/apple-touch-icon.png
apps/main-app/public/apple-touch-icon.png
public/components/ui/badge.tsx
apps/main-app/public/components/ui/badge.tsx
public/components/ui/badge.tsx
apps/main-app/public/components/ui/badge.tsx
public/components/ui/card.tsx
apps/main-app/public/components/ui/card.tsx
public/components/ui/card.tsx
apps/main-app/public/components/ui/card.tsx
public/components/ui/checkbox.tsx
apps/main-app/public/components/ui/checkbox.tsx
public/components/ui/checkbox.tsx
apps/main-app/public/components/ui/checkbox.tsx
public/components/ui/code-block.tsx
apps/main-app/public/components/ui/code-block.tsx
public/components/ui/code-block.tsx
apps/main-app/public/components/ui/code-block.tsx
public/components/ui/dialog.tsx
apps/main-app/public/components/ui/dialog.tsx
public/components/ui/dialog.tsx
apps/main-app/public/components/ui/dialog.tsx
public/components/ui/input.tsx
apps/main-app/public/components/ui/input.tsx
public/components/ui/input.tsx
apps/main-app/public/components/ui/input.tsx
public/components/ui/label.tsx
apps/main-app/public/components/ui/label.tsx
public/components/ui/label.tsx
apps/main-app/public/components/ui/label.tsx
public/components/ui/radio-group.tsx
apps/main-app/public/components/ui/radio-group.tsx
public/components/ui/radio-group.tsx
apps/main-app/public/components/ui/radio-group.tsx
public/components/ui/skeleton.tsx
apps/main-app/public/components/ui/skeleton.tsx
public/components/ui/skeleton.tsx
apps/main-app/public/components/ui/skeleton.tsx
public/components/ui/tabs.tsx
apps/main-app/public/components/ui/tabs.tsx
public/components/ui/tabs.tsx
apps/main-app/public/components/ui/tabs.tsx
public/editor/components/TabSkeleton.tsx
apps/main-app/public/editor/components/TabSkeleton.tsx
public/editor/components/TabSkeleton.tsx
apps/main-app/public/editor/components/TabSkeleton.tsx
public/editor/editor.tsx
apps/main-app/public/editor/editor.tsx
public/editor/editor.tsx
apps/main-app/public/editor/editor.tsx
public/editor/hooks/useDomainData.ts
apps/main-app/public/editor/hooks/useDomainData.ts
public/editor/hooks/useDomainData.ts
apps/main-app/public/editor/hooks/useDomainData.ts
public/editor/hooks/useSiteData.ts
apps/main-app/public/editor/hooks/useSiteData.ts
public/editor/hooks/useSiteData.ts
apps/main-app/public/editor/hooks/useSiteData.ts
public/editor/hooks/useUserInfo.ts
apps/main-app/public/editor/hooks/useUserInfo.ts
public/editor/hooks/useUserInfo.ts
apps/main-app/public/editor/hooks/useUserInfo.ts
public/editor/index.html
apps/main-app/public/editor/index.html
public/editor/index.html
apps/main-app/public/editor/index.html
public/editor/tabs/CLITab.tsx
apps/main-app/public/editor/tabs/CLITab.tsx
public/editor/tabs/CLITab.tsx
apps/main-app/public/editor/tabs/CLITab.tsx
public/editor/tabs/DomainsTab.tsx
apps/main-app/public/editor/tabs/DomainsTab.tsx
public/editor/tabs/DomainsTab.tsx
apps/main-app/public/editor/tabs/DomainsTab.tsx
public/editor/tabs/SitesTab.tsx
apps/main-app/public/editor/tabs/SitesTab.tsx
public/editor/tabs/SitesTab.tsx
apps/main-app/public/editor/tabs/SitesTab.tsx
public/editor/tabs/UploadTab.tsx
apps/main-app/public/editor/tabs/UploadTab.tsx
public/editor/tabs/UploadTab.tsx
apps/main-app/public/editor/tabs/UploadTab.tsx
public/favicon-16x16.png
apps/main-app/public/favicon-16x16.png
public/favicon-16x16.png
apps/main-app/public/favicon-16x16.png
public/favicon-32x32.png
apps/main-app/public/favicon-32x32.png
public/favicon-32x32.png
apps/main-app/public/favicon-32x32.png
public/favicon.ico
apps/main-app/public/favicon.ico
public/favicon.ico
apps/main-app/public/favicon.ico
public/index.html
apps/main-app/public/index.html
public/index.html
apps/main-app/public/index.html
public/index.tsx
apps/main-app/public/index.tsx
public/index.tsx
apps/main-app/public/index.tsx
public/layouts/index.tsx
apps/main-app/public/layouts/index.tsx
public/layouts/index.tsx
apps/main-app/public/layouts/index.tsx
public/lib/api.ts
apps/main-app/public/lib/api.ts
public/lib/api.ts
apps/main-app/public/lib/api.ts
public/lib/utils.ts
apps/main-app/public/lib/utils.ts
public/lib/utils.ts
apps/main-app/public/lib/utils.ts
public/onboarding/index.html
apps/main-app/public/onboarding/index.html
public/onboarding/index.html
apps/main-app/public/onboarding/index.html
public/onboarding/onboarding.tsx
apps/main-app/public/onboarding/onboarding.tsx
public/onboarding/onboarding.tsx
apps/main-app/public/onboarding/onboarding.tsx
public/robots.txt
apps/main-app/public/robots.txt
public/robots.txt
apps/main-app/public/robots.txt
public/screenshots/atproto-ui_wisp_place.png
apps/main-app/public/screenshots/atproto-ui_wisp_place.png
public/screenshots/atproto-ui_wisp_place.png
apps/main-app/public/screenshots/atproto-ui_wisp_place.png
public/screenshots/avalanche_moe.png
apps/main-app/public/screenshots/avalanche_moe.png
public/screenshots/avalanche_moe.png
apps/main-app/public/screenshots/avalanche_moe.png
public/screenshots/brotosolar_wisp_place.png
apps/main-app/public/screenshots/brotosolar_wisp_place.png
public/screenshots/brotosolar_wisp_place.png
apps/main-app/public/screenshots/brotosolar_wisp_place.png
public/screenshots/erisa_wisp_place.png
apps/main-app/public/screenshots/erisa_wisp_place.png
public/screenshots/erisa_wisp_place.png
apps/main-app/public/screenshots/erisa_wisp_place.png
public/screenshots/hayden_moe.png
apps/main-app/public/screenshots/hayden_moe.png
public/screenshots/hayden_moe.png
apps/main-app/public/screenshots/hayden_moe.png
public/screenshots/kot_pink.png
apps/main-app/public/screenshots/kot_pink.png
public/screenshots/kot_pink.png
apps/main-app/public/screenshots/kot_pink.png
public/screenshots/moover_wisp_place.png
apps/main-app/public/screenshots/moover_wisp_place.png
public/screenshots/moover_wisp_place.png
apps/main-app/public/screenshots/moover_wisp_place.png
public/screenshots/nekomimi_pet.png
apps/main-app/public/screenshots/nekomimi_pet.png
public/screenshots/nekomimi_pet.png
apps/main-app/public/screenshots/nekomimi_pet.png
public/screenshots/pdsls_wisp_place.png
apps/main-app/public/screenshots/pdsls_wisp_place.png
public/screenshots/pdsls_wisp_place.png
apps/main-app/public/screenshots/pdsls_wisp_place.png
public/screenshots/plc-bench_wisp_place.png
apps/main-app/public/screenshots/plc-bench_wisp_place.png
public/screenshots/plc-bench_wisp_place.png
apps/main-app/public/screenshots/plc-bench_wisp_place.png
public/screenshots/rainygoo_se.png
apps/main-app/public/screenshots/rainygoo_se.png
public/screenshots/rainygoo_se.png
apps/main-app/public/screenshots/rainygoo_se.png
public/screenshots/rd_jbcrn_dev.png
apps/main-app/public/screenshots/rd_jbcrn_dev.png
public/screenshots/rd_jbcrn_dev.png
apps/main-app/public/screenshots/rd_jbcrn_dev.png
public/screenshots/sites_wisp_place_did_plc_3whdb534faiczugsz5fnohh6_rafa.png
apps/main-app/public/screenshots/sites_wisp_place_did_plc_3whdb534faiczugsz5fnohh6_rafa.png
public/screenshots/sites_wisp_place_did_plc_3whdb534faiczugsz5fnohh6_rafa.png
apps/main-app/public/screenshots/sites_wisp_place_did_plc_3whdb534faiczugsz5fnohh6_rafa.png
public/screenshots/sites_wisp_place_did_plc_524tuhdhh3m7li5gycdn6boe_plcbundle-watch.png
apps/main-app/public/screenshots/sites_wisp_place_did_plc_524tuhdhh3m7li5gycdn6boe_plcbundle-watch.png
public/screenshots/sites_wisp_place_did_plc_524tuhdhh3m7li5gycdn6boe_plcbundle-watch.png
apps/main-app/public/screenshots/sites_wisp_place_did_plc_524tuhdhh3m7li5gycdn6boe_plcbundle-watch.png
public/screenshots/system_grdnsys_no.png
apps/main-app/public/screenshots/system_grdnsys_no.png
public/screenshots/system_grdnsys_no.png
apps/main-app/public/screenshots/system_grdnsys_no.png
public/screenshots/tealfm_indexx_dev.png
apps/main-app/public/screenshots/tealfm_indexx_dev.png
public/screenshots/tealfm_indexx_dev.png
apps/main-app/public/screenshots/tealfm_indexx_dev.png
public/screenshots/tigwyk_wisp_place.png
apps/main-app/public/screenshots/tigwyk_wisp_place.png
public/screenshots/tigwyk_wisp_place.png
apps/main-app/public/screenshots/tigwyk_wisp_place.png
public/screenshots/wfr_jbc_lol.png
apps/main-app/public/screenshots/wfr_jbc_lol.png
public/screenshots/wfr_jbc_lol.png
apps/main-app/public/screenshots/wfr_jbc_lol.png
public/screenshots/wisp_jbc_lol.png
apps/main-app/public/screenshots/wisp_jbc_lol.png
public/screenshots/wisp_jbc_lol.png
apps/main-app/public/screenshots/wisp_jbc_lol.png
public/screenshots/wisp_soverth_f5_si.png
apps/main-app/public/screenshots/wisp_soverth_f5_si.png
public/screenshots/wisp_soverth_f5_si.png
apps/main-app/public/screenshots/wisp_soverth_f5_si.png
public/screenshots/www_miriscient_org.png
apps/main-app/public/screenshots/www_miriscient_org.png
public/screenshots/www_miriscient_org.png
apps/main-app/public/screenshots/www_miriscient_org.png
public/screenshots/www_wlo_moe.png
apps/main-app/public/screenshots/www_wlo_moe.png
public/screenshots/www_wlo_moe.png
apps/main-app/public/screenshots/www_wlo_moe.png
public/site.webmanifest
apps/main-app/public/site.webmanifest
public/site.webmanifest
apps/main-app/public/site.webmanifest
public/styles/global.css
apps/main-app/public/styles/global.css
public/styles/global.css
apps/main-app/public/styles/global.css
public/transparent-full-size-ico.png
apps/main-app/public/transparent-full-size-ico.png
public/transparent-full-size-ico.png
apps/main-app/public/transparent-full-size-ico.png
scripts/change-admin-password.ts
apps/main-app/scripts/change-admin-password.ts
scripts/change-admin-password.ts
apps/main-app/scripts/change-admin-password.ts
+1
-1
scripts/create-admin.ts
apps/main-app/scripts/create-admin.ts
+1
-1
scripts/create-admin.ts
apps/main-app/scripts/create-admin.ts
scripts/screenshot-sites.ts
apps/main-app/scripts/screenshot-sites.ts
scripts/screenshot-sites.ts
apps/main-app/scripts/screenshot-sites.ts
+7
-3
src/index.ts
apps/main-app/src/index.ts
+7
-3
src/index.ts
apps/main-app/src/index.ts
···
4
4
import { staticPlugin } from '@elysiajs/static'
5
5
6
6
import type { Config } from './lib/types'
7
-
import { BASE_HOST } from './lib/constants'
7
+
import { BASE_HOST } from '@wisp/constants'
8
8
import {
9
9
createClientMetadata,
10
10
getOAuthClient,
···
20
20
import { siteRoutes } from './routes/site'
21
21
import { csrfProtection } from './lib/csrf'
22
22
import { DNSVerificationWorker } from './lib/dns-verification-worker'
23
-
import { logger, logCollector, observabilityMiddleware } from './lib/observability'
23
+
import { createLogger, logCollector } from '@wisp/observability'
24
+
import { observabilityMiddleware } from '@wisp/observability/middleware/elysia'
24
25
import { promptAdminSetup } from './lib/admin-auth'
25
26
import { adminRoutes } from './routes/admin'
27
+
28
+
const logger = createLogger('main-app')
26
29
27
30
const config: Config = {
28
31
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as Config['domain'],
···
112
115
.use(adminRoutes(cookieSecret))
113
116
.use(
114
117
await staticPlugin({
118
+
assets: 'apps/main-app/public',
115
119
prefix: '/'
116
120
})
117
121
)
···
148
152
const glob = new Glob('*.png')
149
153
const screenshots: string[] = []
150
154
151
-
for await (const file of glob.scan('./public/screenshots')) {
155
+
for await (const file of glob.scan('./apps/main-app/public/screenshots')) {
152
156
screenshots.push(file)
153
157
}
154
158
+1
-1
src/lexicons/index.ts
packages/@wisp/lexicons/src/index.ts
+1
-1
src/lexicons/index.ts
packages/@wisp/lexicons/src/index.ts
···
9
9
type MethodConfigOrHandler,
10
10
createServer as createXrpcServer,
11
11
} from '@atproto/xrpc-server'
12
-
import { schemas } from './lexicons.js'
12
+
import { schemas } from './lexicons'
13
13
14
14
export function createServer(options?: XrpcOptions): Server {
15
15
return new Server(options)
+1
-1
src/lexicons/lexicons.ts
packages/@wisp/lexicons/src/lexicons.ts
+1
-1
src/lexicons/lexicons.ts
packages/@wisp/lexicons/src/lexicons.ts
-110
src/lexicons/types/place/wisp/fs.ts
-110
src/lexicons/types/place/wisp/fs.ts
···
1
-
/**
2
-
* GENERATED CODE - DO NOT MODIFY
3
-
*/
4
-
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
5
-
import { CID } from 'multiformats/cid'
6
-
import { validate as _validate } from '../../../lexicons'
7
-
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
8
-
9
-
const is$typed = _is$typed,
10
-
validate = _validate
11
-
const id = 'place.wisp.fs'
12
-
13
-
export interface Main {
14
-
$type: 'place.wisp.fs'
15
-
site: string
16
-
root: Directory
17
-
fileCount?: number
18
-
createdAt: string
19
-
[k: string]: unknown
20
-
}
21
-
22
-
const hashMain = 'main'
23
-
24
-
export function isMain<V>(v: V) {
25
-
return is$typed(v, id, hashMain)
26
-
}
27
-
28
-
export function validateMain<V>(v: V) {
29
-
return validate<Main & V>(v, id, hashMain, true)
30
-
}
31
-
32
-
export {
33
-
type Main as Record,
34
-
isMain as isRecord,
35
-
validateMain as validateRecord,
36
-
}
37
-
38
-
export interface File {
39
-
$type?: 'place.wisp.fs#file'
40
-
type: 'file'
41
-
/** Content blob ref */
42
-
blob: BlobRef
43
-
/** Content encoding (e.g., gzip for compressed files) */
44
-
encoding?: 'gzip'
45
-
/** Original MIME type before compression */
46
-
mimeType?: string
47
-
/** True if blob content is base64-encoded (used to bypass PDS content sniffing) */
48
-
base64?: boolean
49
-
}
50
-
51
-
const hashFile = 'file'
52
-
53
-
export function isFile<V>(v: V) {
54
-
return is$typed(v, id, hashFile)
55
-
}
56
-
57
-
export function validateFile<V>(v: V) {
58
-
return validate<File & V>(v, id, hashFile)
59
-
}
60
-
61
-
export interface Directory {
62
-
$type?: 'place.wisp.fs#directory'
63
-
type: 'directory'
64
-
entries: Entry[]
65
-
}
66
-
67
-
const hashDirectory = 'directory'
68
-
69
-
export function isDirectory<V>(v: V) {
70
-
return is$typed(v, id, hashDirectory)
71
-
}
72
-
73
-
export function validateDirectory<V>(v: V) {
74
-
return validate<Directory & V>(v, id, hashDirectory)
75
-
}
76
-
77
-
export interface Entry {
78
-
$type?: 'place.wisp.fs#entry'
79
-
name: string
80
-
node: $Typed<File> | $Typed<Directory> | $Typed<Subfs> | { $type: string }
81
-
}
82
-
83
-
const hashEntry = 'entry'
84
-
85
-
export function isEntry<V>(v: V) {
86
-
return is$typed(v, id, hashEntry)
87
-
}
88
-
89
-
export function validateEntry<V>(v: V) {
90
-
return validate<Entry & V>(v, id, hashEntry)
91
-
}
92
-
93
-
export interface Subfs {
94
-
$type?: 'place.wisp.fs#subfs'
95
-
type: 'subfs'
96
-
/** AT-URI pointing to a place.wisp.subfs record containing this subtree. */
97
-
subject: string
98
-
/** If true (default), the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false, the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure. */
99
-
flat?: boolean
100
-
}
101
-
102
-
const hashSubfs = 'subfs'
103
-
104
-
export function isSubfs<V>(v: V) {
105
-
return is$typed(v, id, hashSubfs)
106
-
}
107
-
108
-
export function validateSubfs<V>(v: V) {
109
-
return validate<Subfs & V>(v, id, hashSubfs)
110
-
}
-65
src/lexicons/types/place/wisp/settings.ts
-65
src/lexicons/types/place/wisp/settings.ts
···
1
-
/**
2
-
* GENERATED CODE - DO NOT MODIFY
3
-
*/
4
-
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
5
-
import { CID } from 'multiformats/cid'
6
-
import { validate as _validate } from '../../../lexicons'
7
-
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
8
-
9
-
const is$typed = _is$typed,
10
-
validate = _validate
11
-
const id = 'place.wisp.settings'
12
-
13
-
export interface Main {
14
-
$type: 'place.wisp.settings'
15
-
/** Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode. */
16
-
directoryListing: boolean
17
-
/** File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404. */
18
-
spaMode?: string
19
-
/** Custom 404 error page file path. Incompatible with directoryListing and spaMode. */
20
-
custom404?: string
21
-
/** Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified. */
22
-
indexFiles?: string[]
23
-
/** Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically. */
24
-
cleanUrls: boolean
25
-
/** Custom HTTP headers to set on responses */
26
-
headers?: CustomHeader[]
27
-
[k: string]: unknown
28
-
}
29
-
30
-
const hashMain = 'main'
31
-
32
-
export function isMain<V>(v: V) {
33
-
return is$typed(v, id, hashMain)
34
-
}
35
-
36
-
export function validateMain<V>(v: V) {
37
-
return validate<Main & V>(v, id, hashMain, true)
38
-
}
39
-
40
-
export {
41
-
type Main as Record,
42
-
isMain as isRecord,
43
-
validateMain as validateRecord,
44
-
}
45
-
46
-
/** Custom HTTP header configuration */
47
-
export interface CustomHeader {
48
-
$type?: 'place.wisp.settings#customHeader'
49
-
/** HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options') */
50
-
name: string
51
-
/** HTTP header value */
52
-
value: string
53
-
/** Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths. */
54
-
path?: string
55
-
}
56
-
57
-
const hashCustomHeader = 'customHeader'
58
-
59
-
export function isCustomHeader<V>(v: V) {
60
-
return is$typed(v, id, hashCustomHeader)
61
-
}
62
-
63
-
export function validateCustomHeader<V>(v: V) {
64
-
return validate<CustomHeader & V>(v, id, hashCustomHeader)
65
-
}
-107
src/lexicons/types/place/wisp/subfs.ts
-107
src/lexicons/types/place/wisp/subfs.ts
···
1
-
/**
2
-
* GENERATED CODE - DO NOT MODIFY
3
-
*/
4
-
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
5
-
import { CID } from 'multiformats/cid'
6
-
import { validate as _validate } from '../../../lexicons'
7
-
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
8
-
9
-
const is$typed = _is$typed,
10
-
validate = _validate
11
-
const id = 'place.wisp.subfs'
12
-
13
-
export interface Main {
14
-
$type: 'place.wisp.subfs'
15
-
root: Directory
16
-
fileCount?: number
17
-
createdAt: string
18
-
[k: string]: unknown
19
-
}
20
-
21
-
const hashMain = 'main'
22
-
23
-
export function isMain<V>(v: V) {
24
-
return is$typed(v, id, hashMain)
25
-
}
26
-
27
-
export function validateMain<V>(v: V) {
28
-
return validate<Main & V>(v, id, hashMain, true)
29
-
}
30
-
31
-
export {
32
-
type Main as Record,
33
-
isMain as isRecord,
34
-
validateMain as validateRecord,
35
-
}
36
-
37
-
export interface File {
38
-
$type?: 'place.wisp.subfs#file'
39
-
type: 'file'
40
-
/** Content blob ref */
41
-
blob: BlobRef
42
-
/** Content encoding (e.g., gzip for compressed files) */
43
-
encoding?: 'gzip'
44
-
/** Original MIME type before compression */
45
-
mimeType?: string
46
-
/** True if blob content is base64-encoded (used to bypass PDS content sniffing) */
47
-
base64?: boolean
48
-
}
49
-
50
-
const hashFile = 'file'
51
-
52
-
export function isFile<V>(v: V) {
53
-
return is$typed(v, id, hashFile)
54
-
}
55
-
56
-
export function validateFile<V>(v: V) {
57
-
return validate<File & V>(v, id, hashFile)
58
-
}
59
-
60
-
export interface Directory {
61
-
$type?: 'place.wisp.subfs#directory'
62
-
type: 'directory'
63
-
entries: Entry[]
64
-
}
65
-
66
-
const hashDirectory = 'directory'
67
-
68
-
export function isDirectory<V>(v: V) {
69
-
return is$typed(v, id, hashDirectory)
70
-
}
71
-
72
-
export function validateDirectory<V>(v: V) {
73
-
return validate<Directory & V>(v, id, hashDirectory)
74
-
}
75
-
76
-
export interface Entry {
77
-
$type?: 'place.wisp.subfs#entry'
78
-
name: string
79
-
node: $Typed<File> | $Typed<Directory> | $Typed<Subfs> | { $type: string }
80
-
}
81
-
82
-
const hashEntry = 'entry'
83
-
84
-
export function isEntry<V>(v: V) {
85
-
return is$typed(v, id, hashEntry)
86
-
}
87
-
88
-
export function validateEntry<V>(v: V) {
89
-
return validate<Entry & V>(v, id, hashEntry)
90
-
}
91
-
92
-
export interface Subfs {
93
-
$type?: 'place.wisp.subfs#subfs'
94
-
type: 'subfs'
95
-
/** AT-URI pointing to another place.wisp.subfs record for nested subtrees. When expanded, the referenced record's root entries are merged (flattened) into the parent directory, allowing recursive splitting of large directory structures. */
96
-
subject: string
97
-
}
98
-
99
-
const hashSubfs = 'subfs'
100
-
101
-
export function isSubfs<V>(v: V) {
102
-
return is$typed(v, id, hashSubfs)
103
-
}
104
-
105
-
export function validateSubfs<V>(v: V) {
106
-
return validate<Subfs & V>(v, id, hashSubfs)
107
-
}
-82
src/lexicons/util.ts
-82
src/lexicons/util.ts
···
1
-
/**
2
-
* GENERATED CODE - DO NOT MODIFY
3
-
*/
4
-
5
-
import { type ValidationResult } from '@atproto/lexicon'
6
-
7
-
export type OmitKey<T, K extends keyof T> = {
8
-
[K2 in keyof T as K2 extends K ? never : K2]: T[K2]
9
-
}
10
-
11
-
export type $Typed<V, T extends string = string> = V & { $type: T }
12
-
export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'>
13
-
14
-
export type $Type<Id extends string, Hash extends string> = Hash extends 'main'
15
-
? Id
16
-
: `${Id}#${Hash}`
17
-
18
-
function isObject<V>(v: V): v is V & object {
19
-
return v != null && typeof v === 'object'
20
-
}
21
-
22
-
function is$type<Id extends string, Hash extends string>(
23
-
$type: unknown,
24
-
id: Id,
25
-
hash: Hash,
26
-
): $type is $Type<Id, Hash> {
27
-
return hash === 'main'
28
-
? $type === id
29
-
: // $type === `${id}#${hash}`
30
-
typeof $type === 'string' &&
31
-
$type.length === id.length + 1 + hash.length &&
32
-
$type.charCodeAt(id.length) === 35 /* '#' */ &&
33
-
$type.startsWith(id) &&
34
-
$type.endsWith(hash)
35
-
}
36
-
37
-
export type $TypedObject<
38
-
V,
39
-
Id extends string,
40
-
Hash extends string,
41
-
> = V extends {
42
-
$type: $Type<Id, Hash>
43
-
}
44
-
? V
45
-
: V extends { $type?: string }
46
-
? V extends { $type?: infer T extends $Type<Id, Hash> }
47
-
? V & { $type: T }
48
-
: never
49
-
: V & { $type: $Type<Id, Hash> }
50
-
51
-
export function is$typed<V, Id extends string, Hash extends string>(
52
-
v: V,
53
-
id: Id,
54
-
hash: Hash,
55
-
): v is $TypedObject<V, Id, Hash> {
56
-
return isObject(v) && '$type' in v && is$type(v.$type, id, hash)
57
-
}
58
-
59
-
export function maybe$typed<V, Id extends string, Hash extends string>(
60
-
v: V,
61
-
id: Id,
62
-
hash: Hash,
63
-
): v is V & object & { $type?: $Type<Id, Hash> } {
64
-
return (
65
-
isObject(v) &&
66
-
('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true)
67
-
)
68
-
}
69
-
70
-
export type Validator<R = unknown> = (v: unknown) => ValidationResult<R>
71
-
export type ValidatorParam<V extends Validator> =
72
-
V extends Validator<infer R> ? R : never
73
-
74
-
/**
75
-
* Utility function that allows to convert a "validate*" utility function into a
76
-
* type predicate.
77
-
*/
78
-
export function asPredicate<V extends Validator>(validate: V) {
79
-
return function <T>(v: T): v is T & ValidatorParam<V> {
80
-
return validate(v).success
81
-
}
82
-
}
src/lib/admin-auth.ts
apps/main-app/src/lib/admin-auth.ts
src/lib/admin-auth.ts
apps/main-app/src/lib/admin-auth.ts
-4
src/lib/constants.ts
-4
src/lib/constants.ts
src/lib/csrf.test.ts
apps/main-app/src/lib/csrf.test.ts
src/lib/csrf.test.ts
apps/main-app/src/lib/csrf.test.ts
src/lib/csrf.ts
apps/main-app/src/lib/csrf.ts
src/lib/csrf.ts
apps/main-app/src/lib/csrf.ts
src/lib/db.test.ts
apps/main-app/src/lib/db.test.ts
src/lib/db.test.ts
apps/main-app/src/lib/db.test.ts
+1
-1
src/lib/db.ts
apps/main-app/src/lib/db.ts
+1
-1
src/lib/db.ts
apps/main-app/src/lib/db.ts
src/lib/dns-verification-worker.ts
apps/main-app/src/lib/dns-verification-worker.ts
src/lib/dns-verification-worker.ts
apps/main-app/src/lib/dns-verification-worker.ts
src/lib/dns-verify.ts
apps/main-app/src/lib/dns-verify.ts
src/lib/dns-verify.ts
apps/main-app/src/lib/dns-verify.ts
-46
src/lib/logger.ts
-46
src/lib/logger.ts
···
1
-
// Secure logging utility - only verbose in development mode
2
-
const isDev = process.env.NODE_ENV !== 'production';
3
-
4
-
export const logger = {
5
-
// Always log these (safe for production)
6
-
info: (...args: any[]) => {
7
-
console.log(...args);
8
-
},
9
-
10
-
// Only log in development (may contain sensitive info)
11
-
debug: (...args: any[]) => {
12
-
if (isDev) {
13
-
console.debug(...args);
14
-
}
15
-
},
16
-
17
-
// Warning logging (always logged but may be sanitized in production)
18
-
warn: (message: string, context?: Record<string, any>) => {
19
-
if (isDev) {
20
-
console.warn(message, context);
21
-
} else {
22
-
console.warn(message);
23
-
}
24
-
},
25
-
26
-
// Safe error logging - sanitizes in production
27
-
error: (message: string, error?: any) => {
28
-
if (isDev) {
29
-
// Development: log full error details
30
-
console.error(message, error);
31
-
} else {
32
-
// Production: log only the message, not error details
33
-
console.error(message);
34
-
}
35
-
},
36
-
37
-
// Log error with context but sanitize sensitive data in production
38
-
errorWithContext: (message: string, context?: Record<string, any>, error?: any) => {
39
-
if (isDev) {
40
-
console.error(message, context, error);
41
-
} else {
42
-
// In production, only log the message
43
-
console.error(message);
44
-
}
45
-
}
46
-
};
+1
-1
src/lib/oauth-client.ts
apps/main-app/src/lib/oauth-client.ts
+1
-1
src/lib/oauth-client.ts
apps/main-app/src/lib/oauth-client.ts
···
70
70
// Check if expired
71
71
const expiresAt = Number(result[0].expires_at);
72
72
if (expiresAt && now > expiresAt) {
73
-
logger.debug('[sessionStore] Session expired, deleting', sub);
73
+
logger.debug('[sessionStore] Session expired, deleting', { sub });
74
74
await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
75
75
return undefined;
76
76
}
-339
src/lib/observability.ts
-339
src/lib/observability.ts
···
1
-
// DIY Observability - Logs, Metrics, and Error Tracking
2
-
// Types
3
-
export interface LogEntry {
4
-
id: string
5
-
timestamp: Date
6
-
level: 'info' | 'warn' | 'error' | 'debug'
7
-
message: string
8
-
service: string
9
-
context?: Record<string, any>
10
-
traceId?: string
11
-
eventType?: string
12
-
}
13
-
14
-
export interface ErrorEntry {
15
-
id: string
16
-
timestamp: Date
17
-
message: string
18
-
stack?: string
19
-
service: string
20
-
context?: Record<string, any>
21
-
count: number // How many times this error occurred
22
-
lastSeen: Date
23
-
}
24
-
25
-
export interface MetricEntry {
26
-
timestamp: Date
27
-
path: string
28
-
method: string
29
-
statusCode: number
30
-
duration: number // in milliseconds
31
-
service: string
32
-
}
33
-
34
-
export interface DatabaseStats {
35
-
totalSites: number
36
-
totalDomains: number
37
-
totalCustomDomains: number
38
-
recentSites: any[]
39
-
recentDomains: any[]
40
-
}
41
-
42
-
// In-memory storage with rotation
43
-
const MAX_LOGS = 5000
44
-
const MAX_ERRORS = 500
45
-
const MAX_METRICS = 10000
46
-
47
-
const logs: LogEntry[] = []
48
-
const errors: Map<string, ErrorEntry> = new Map()
49
-
const metrics: MetricEntry[] = []
50
-
51
-
// Helper to generate unique IDs
52
-
let logCounter = 0
53
-
let errorCounter = 0
54
-
55
-
function generateId(prefix: string, counter: number): string {
56
-
return `${prefix}-${Date.now()}-${counter}`
57
-
}
58
-
59
-
// Helper to extract event type from message
60
-
function extractEventType(message: string): string | undefined {
61
-
const match = message.match(/^\[([^\]]+)\]/)
62
-
return match ? match[1] : undefined
63
-
}
64
-
65
-
// Log collector
66
-
export const logCollector = {
67
-
log(level: LogEntry['level'], message: string, service: string, context?: Record<string, any>, traceId?: string) {
68
-
const entry: LogEntry = {
69
-
id: generateId('log', logCounter++),
70
-
timestamp: new Date(),
71
-
level,
72
-
message,
73
-
service,
74
-
context,
75
-
traceId,
76
-
eventType: extractEventType(message)
77
-
}
78
-
79
-
logs.unshift(entry)
80
-
81
-
// Rotate if needed
82
-
if (logs.length > MAX_LOGS) {
83
-
logs.splice(MAX_LOGS)
84
-
}
85
-
86
-
// Also log to console for compatibility
87
-
const contextStr = context ? ` ${JSON.stringify(context)}` : ''
88
-
const traceStr = traceId ? ` [trace:${traceId}]` : ''
89
-
console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`)
90
-
},
91
-
92
-
info(message: string, service: string, context?: Record<string, any>, traceId?: string) {
93
-
this.log('info', message, service, context, traceId)
94
-
},
95
-
96
-
warn(message: string, service: string, context?: Record<string, any>, traceId?: string) {
97
-
this.log('warn', message, service, context, traceId)
98
-
},
99
-
100
-
error(message: string, service: string, error?: any, context?: Record<string, any>, traceId?: string) {
101
-
const ctx = { ...context }
102
-
if (error instanceof Error) {
103
-
ctx.error = error.message
104
-
ctx.stack = error.stack
105
-
} else if (error) {
106
-
ctx.error = String(error)
107
-
}
108
-
this.log('error', message, service, ctx, traceId)
109
-
110
-
// Also track in errors
111
-
errorTracker.track(message, service, error, context)
112
-
},
113
-
114
-
debug(message: string, service: string, context?: Record<string, any>, traceId?: string) {
115
-
if (process.env.NODE_ENV !== 'production') {
116
-
this.log('debug', message, service, context, traceId)
117
-
}
118
-
},
119
-
120
-
getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) {
121
-
let filtered = [...logs]
122
-
123
-
if (filter?.level) {
124
-
filtered = filtered.filter(log => log.level === filter.level)
125
-
}
126
-
127
-
if (filter?.service) {
128
-
filtered = filtered.filter(log => log.service === filter.service)
129
-
}
130
-
131
-
if (filter?.eventType) {
132
-
filtered = filtered.filter(log => log.eventType === filter.eventType)
133
-
}
134
-
135
-
if (filter?.search) {
136
-
const search = filter.search.toLowerCase()
137
-
filtered = filtered.filter(log =>
138
-
log.message.toLowerCase().includes(search) ||
139
-
(log.context ? JSON.stringify(log.context).toLowerCase().includes(search) : false)
140
-
)
141
-
}
142
-
143
-
const limit = filter?.limit || 100
144
-
return filtered.slice(0, limit)
145
-
},
146
-
147
-
clear() {
148
-
logs.length = 0
149
-
}
150
-
}
151
-
152
-
// Error tracker with deduplication
153
-
export const errorTracker = {
154
-
track(message: string, service: string, error?: any, context?: Record<string, any>) {
155
-
const key = `${service}:${message}`
156
-
157
-
const existing = errors.get(key)
158
-
if (existing) {
159
-
existing.count++
160
-
existing.lastSeen = new Date()
161
-
if (context) {
162
-
existing.context = { ...existing.context, ...context }
163
-
}
164
-
} else {
165
-
const entry: ErrorEntry = {
166
-
id: generateId('error', errorCounter++),
167
-
timestamp: new Date(),
168
-
message,
169
-
service,
170
-
context,
171
-
count: 1,
172
-
lastSeen: new Date()
173
-
}
174
-
175
-
if (error instanceof Error) {
176
-
entry.stack = error.stack
177
-
}
178
-
179
-
errors.set(key, entry)
180
-
181
-
// Rotate if needed
182
-
if (errors.size > MAX_ERRORS) {
183
-
const oldest = Array.from(errors.keys())[0]
184
-
errors.delete(oldest)
185
-
}
186
-
}
187
-
},
188
-
189
-
getErrors(filter?: { service?: string; limit?: number }) {
190
-
let filtered = Array.from(errors.values())
191
-
192
-
if (filter?.service) {
193
-
filtered = filtered.filter(err => err.service === filter.service)
194
-
}
195
-
196
-
// Sort by last seen (most recent first)
197
-
filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime())
198
-
199
-
const limit = filter?.limit || 100
200
-
return filtered.slice(0, limit)
201
-
},
202
-
203
-
clear() {
204
-
errors.clear()
205
-
}
206
-
}
207
-
208
-
// Metrics collector
209
-
export const metricsCollector = {
210
-
recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) {
211
-
const entry: MetricEntry = {
212
-
timestamp: new Date(),
213
-
path,
214
-
method,
215
-
statusCode,
216
-
duration,
217
-
service
218
-
}
219
-
220
-
metrics.unshift(entry)
221
-
222
-
// Rotate if needed
223
-
if (metrics.length > MAX_METRICS) {
224
-
metrics.splice(MAX_METRICS)
225
-
}
226
-
},
227
-
228
-
getMetrics(filter?: { service?: string; timeWindow?: number }) {
229
-
let filtered = [...metrics]
230
-
231
-
if (filter?.service) {
232
-
filtered = filtered.filter(m => m.service === filter.service)
233
-
}
234
-
235
-
if (filter?.timeWindow) {
236
-
const cutoff = Date.now() - filter.timeWindow
237
-
filtered = filtered.filter(m => m.timestamp.getTime() > cutoff)
238
-
}
239
-
240
-
return filtered
241
-
},
242
-
243
-
getStats(service?: string, timeWindow: number = 3600000) {
244
-
const filtered = this.getMetrics({ service, timeWindow })
245
-
246
-
if (filtered.length === 0) {
247
-
return {
248
-
totalRequests: 0,
249
-
avgDuration: 0,
250
-
p50Duration: 0,
251
-
p95Duration: 0,
252
-
p99Duration: 0,
253
-
errorRate: 0,
254
-
requestsPerMinute: 0
255
-
}
256
-
}
257
-
258
-
const durations = filtered.map(m => m.duration).sort((a, b) => a - b)
259
-
const totalDuration = durations.reduce((sum, d) => sum + d, 0)
260
-
const errors = filtered.filter(m => m.statusCode >= 400).length
261
-
262
-
const p50 = durations[Math.floor(durations.length * 0.5)]
263
-
const p95 = durations[Math.floor(durations.length * 0.95)]
264
-
const p99 = durations[Math.floor(durations.length * 0.99)]
265
-
266
-
const timeWindowMinutes = timeWindow / 60000
267
-
268
-
return {
269
-
totalRequests: filtered.length,
270
-
avgDuration: Math.round(totalDuration / filtered.length),
271
-
p50Duration: Math.round(p50),
272
-
p95Duration: Math.round(p95),
273
-
p99Duration: Math.round(p99),
274
-
errorRate: (errors / filtered.length) * 100,
275
-
requestsPerMinute: Math.round(filtered.length / timeWindowMinutes)
276
-
}
277
-
},
278
-
279
-
clear() {
280
-
metrics.length = 0
281
-
}
282
-
}
283
-
284
-
// Elysia middleware for request timing
285
-
export function observabilityMiddleware(service: string) {
286
-
return {
287
-
beforeHandle: ({ request }: any) => {
288
-
// Store start time on request object
289
-
(request as any).__startTime = Date.now()
290
-
},
291
-
afterHandle: ({ request, set }: any) => {
292
-
const duration = Date.now() - ((request as any).__startTime || Date.now())
293
-
const url = new URL(request.url)
294
-
295
-
metricsCollector.recordRequest(
296
-
url.pathname,
297
-
request.method,
298
-
set.status || 200,
299
-
duration,
300
-
service
301
-
)
302
-
},
303
-
onError: ({ request, error, set }: any) => {
304
-
const duration = Date.now() - ((request as any).__startTime || Date.now())
305
-
const url = new URL(request.url)
306
-
307
-
metricsCollector.recordRequest(
308
-
url.pathname,
309
-
request.method,
310
-
set.status || 500,
311
-
duration,
312
-
service
313
-
)
314
-
315
-
// Don't log 404 errors
316
-
const statusCode = set.status || 500
317
-
if (statusCode !== 404) {
318
-
logCollector.error(
319
-
`Request failed: ${request.method} ${url.pathname}`,
320
-
service,
321
-
error,
322
-
{ statusCode }
323
-
)
324
-
}
325
-
}
326
-
}
327
-
}
328
-
329
-
// Export singleton logger for easy access
330
-
export const logger = {
331
-
info: (message: string, context?: Record<string, any>) =>
332
-
logCollector.info(message, 'main-app', context),
333
-
warn: (message: string, context?: Record<string, any>) =>
334
-
logCollector.warn(message, 'main-app', context),
335
-
error: (message: string, error?: any, context?: Record<string, any>) =>
336
-
logCollector.error(message, 'main-app', error, context),
337
-
debug: (message: string, context?: Record<string, any>) =>
338
-
logCollector.debug(message, 'main-app', context)
339
-
}
+2
-2
src/lib/slingshot-handle-resolver.ts
apps/main-app/src/lib/slingshot-handle-resolver.ts
+2
-2
src/lib/slingshot-handle-resolver.ts
apps/main-app/src/lib/slingshot-handle-resolver.ts
···
7
7
* to work around bugs in atproto-oauth-node when handles have redirects
8
8
* in their well-known configuration.
9
9
*
10
-
* Uses: https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle
10
+
* Uses: https://slingshot.wisp.place/xrpc/com.atproto.identity.resolveHandle
11
11
*/
12
12
export class SlingshotHandleResolver implements HandleResolver {
13
-
private readonly endpoint = 'https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle';
13
+
private readonly endpoint = 'https://slingshot.wisp.place/xrpc/com.atproto.identity.resolveHandle';
14
14
15
15
async resolve(handle: string, options?: ResolveHandleOptions): Promise<ResolvedHandle> {
16
16
try {
src/lib/sync-sites.ts
apps/main-app/src/lib/sync-sites.ts
src/lib/sync-sites.ts
apps/main-app/src/lib/sync-sites.ts
src/lib/types.ts
apps/main-app/src/lib/types.ts
src/lib/types.ts
apps/main-app/src/lib/types.ts
+3
-1
src/lib/upload-jobs.ts
apps/main-app/src/lib/upload-jobs.ts
+3
-1
src/lib/upload-jobs.ts
apps/main-app/src/lib/upload-jobs.ts
src/lib/wisp-auth.ts
apps/main-app/src/lib/wisp-auth.ts
src/lib/wisp-auth.ts
apps/main-app/src/lib/wisp-auth.ts
-1005
src/lib/wisp-utils.test.ts
-1005
src/lib/wisp-utils.test.ts
···
1
-
import { describe, test, expect } from 'bun:test'
2
-
import {
3
-
shouldCompressFile,
4
-
compressFile,
5
-
processUploadedFiles,
6
-
createManifest,
7
-
updateFileBlobs,
8
-
computeCID,
9
-
extractBlobMap,
10
-
type UploadedFile,
11
-
type FileUploadResult,
12
-
} from './wisp-utils'
13
-
import type { Directory } from '../lexicons/types/place/wisp/fs'
14
-
import { gunzipSync } from 'zlib'
15
-
import { BlobRef } from '@atproto/api'
16
-
import { CID } from 'multiformats/cid'
17
-
18
-
// Helper function to create a valid CID for testing
19
-
// Using a real valid CID from actual AT Protocol usage
20
-
const TEST_CID_STRING = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y'
21
-
22
-
function createMockBlobRef(mimeType: string, size: number): BlobRef {
23
-
// Create a properly formatted CID
24
-
const cid = CID.parse(TEST_CID_STRING)
25
-
return new BlobRef(cid as any, mimeType, size)
26
-
}
27
-
28
-
describe('shouldCompressFile', () => {
29
-
test('should compress HTML files', () => {
30
-
expect(shouldCompressFile('text/html')).toBe(true)
31
-
expect(shouldCompressFile('text/html; charset=utf-8')).toBe(true)
32
-
})
33
-
34
-
test('should compress CSS files', () => {
35
-
expect(shouldCompressFile('text/css')).toBe(true)
36
-
})
37
-
38
-
test('should compress JavaScript files', () => {
39
-
expect(shouldCompressFile('text/javascript')).toBe(true)
40
-
expect(shouldCompressFile('application/javascript')).toBe(true)
41
-
expect(shouldCompressFile('application/x-javascript')).toBe(true)
42
-
})
43
-
44
-
test('should compress JSON files', () => {
45
-
expect(shouldCompressFile('application/json')).toBe(true)
46
-
})
47
-
48
-
test('should compress SVG files', () => {
49
-
expect(shouldCompressFile('image/svg+xml')).toBe(true)
50
-
})
51
-
52
-
test('should compress XML files', () => {
53
-
expect(shouldCompressFile('text/xml')).toBe(true)
54
-
expect(shouldCompressFile('application/xml')).toBe(true)
55
-
})
56
-
57
-
test('should compress plain text files', () => {
58
-
expect(shouldCompressFile('text/plain')).toBe(true)
59
-
})
60
-
61
-
test('should NOT compress _redirects file', () => {
62
-
expect(shouldCompressFile('text/plain', '_redirects')).toBe(false)
63
-
expect(shouldCompressFile('text/plain', 'folder/_redirects')).toBe(false)
64
-
expect(shouldCompressFile('application/octet-stream', '_redirects')).toBe(false)
65
-
})
66
-
67
-
test('should NOT compress images', () => {
68
-
expect(shouldCompressFile('image/png')).toBe(false)
69
-
expect(shouldCompressFile('image/jpeg')).toBe(false)
70
-
expect(shouldCompressFile('image/jpg')).toBe(false)
71
-
expect(shouldCompressFile('image/gif')).toBe(false)
72
-
expect(shouldCompressFile('image/webp')).toBe(false)
73
-
})
74
-
75
-
test('should NOT compress videos', () => {
76
-
expect(shouldCompressFile('video/mp4')).toBe(false)
77
-
expect(shouldCompressFile('video/webm')).toBe(false)
78
-
})
79
-
80
-
test('should NOT compress already compressed formats', () => {
81
-
expect(shouldCompressFile('application/zip')).toBe(false)
82
-
expect(shouldCompressFile('application/gzip')).toBe(false)
83
-
expect(shouldCompressFile('application/pdf')).toBe(false)
84
-
})
85
-
86
-
test('should NOT compress fonts', () => {
87
-
expect(shouldCompressFile('font/woff')).toBe(false)
88
-
expect(shouldCompressFile('font/woff2')).toBe(false)
89
-
expect(shouldCompressFile('font/ttf')).toBe(false)
90
-
})
91
-
})
92
-
93
-
describe('compressFile', () => {
94
-
test('should compress text content', () => {
95
-
const content = Buffer.from('Hello, World! '.repeat(100))
96
-
const compressed = compressFile(content)
97
-
98
-
expect(compressed.length).toBeLessThan(content.length)
99
-
100
-
// Verify we can decompress it back
101
-
const decompressed = gunzipSync(compressed)
102
-
expect(decompressed.toString()).toBe(content.toString())
103
-
})
104
-
105
-
test('should compress HTML content significantly', () => {
106
-
const html = `
107
-
<!DOCTYPE html>
108
-
<html>
109
-
<head><title>Test</title></head>
110
-
<body>
111
-
${'<p>Hello World!</p>\n'.repeat(50)}
112
-
</body>
113
-
</html>
114
-
`
115
-
const content = Buffer.from(html)
116
-
const compressed = compressFile(content)
117
-
118
-
expect(compressed.length).toBeLessThan(content.length)
119
-
120
-
// Verify decompression
121
-
const decompressed = gunzipSync(compressed)
122
-
expect(decompressed.toString()).toBe(html)
123
-
})
124
-
125
-
test('should handle empty content', () => {
126
-
const content = Buffer.from('')
127
-
const compressed = compressFile(content)
128
-
const decompressed = gunzipSync(compressed)
129
-
expect(decompressed.toString()).toBe('')
130
-
})
131
-
132
-
test('should produce deterministic compression', () => {
133
-
const content = Buffer.from('Test content')
134
-
const compressed1 = compressFile(content)
135
-
const compressed2 = compressFile(content)
136
-
137
-
expect(compressed1.toString('base64')).toBe(compressed2.toString('base64'))
138
-
})
139
-
})
140
-
141
-
describe('processUploadedFiles', () => {
142
-
test('should process single root-level file', () => {
143
-
const files: UploadedFile[] = [
144
-
{
145
-
name: 'index.html',
146
-
content: Buffer.from('<html></html>'),
147
-
mimeType: 'text/html',
148
-
size: 13,
149
-
},
150
-
]
151
-
152
-
const result = processUploadedFiles(files)
153
-
154
-
expect(result.fileCount).toBe(1)
155
-
expect(result.directory.type).toBe('directory')
156
-
expect(result.directory.entries).toHaveLength(1)
157
-
expect(result.directory.entries[0].name).toBe('index.html')
158
-
159
-
const node = result.directory.entries[0].node
160
-
expect('blob' in node).toBe(true) // It's a file node
161
-
})
162
-
163
-
test('should process multiple root-level files', () => {
164
-
const files: UploadedFile[] = [
165
-
{
166
-
name: 'index.html',
167
-
content: Buffer.from('<html></html>'),
168
-
mimeType: 'text/html',
169
-
size: 13,
170
-
},
171
-
{
172
-
name: 'styles.css',
173
-
content: Buffer.from('body {}'),
174
-
mimeType: 'text/css',
175
-
size: 7,
176
-
},
177
-
{
178
-
name: 'script.js',
179
-
content: Buffer.from('console.log("hi")'),
180
-
mimeType: 'application/javascript',
181
-
size: 17,
182
-
},
183
-
]
184
-
185
-
const result = processUploadedFiles(files)
186
-
187
-
expect(result.fileCount).toBe(3)
188
-
expect(result.directory.entries).toHaveLength(3)
189
-
190
-
const names = result.directory.entries.map(e => e.name)
191
-
expect(names).toContain('index.html')
192
-
expect(names).toContain('styles.css')
193
-
expect(names).toContain('script.js')
194
-
})
195
-
196
-
test('should process files with subdirectories', () => {
197
-
const files: UploadedFile[] = [
198
-
{
199
-
name: 'dist/index.html',
200
-
content: Buffer.from('<html></html>'),
201
-
mimeType: 'text/html',
202
-
size: 13,
203
-
},
204
-
{
205
-
name: 'dist/css/styles.css',
206
-
content: Buffer.from('body {}'),
207
-
mimeType: 'text/css',
208
-
size: 7,
209
-
},
210
-
{
211
-
name: 'dist/js/app.js',
212
-
content: Buffer.from('console.log()'),
213
-
mimeType: 'application/javascript',
214
-
size: 13,
215
-
},
216
-
]
217
-
218
-
const result = processUploadedFiles(files)
219
-
220
-
expect(result.fileCount).toBe(3)
221
-
expect(result.directory.entries).toHaveLength(3) // index.html, css/, js/
222
-
223
-
// Check root has index.html (after base folder removal)
224
-
const indexEntry = result.directory.entries.find(e => e.name === 'index.html')
225
-
expect(indexEntry).toBeDefined()
226
-
227
-
// Check css directory exists
228
-
const cssDir = result.directory.entries.find(e => e.name === 'css')
229
-
expect(cssDir).toBeDefined()
230
-
expect('entries' in cssDir!.node).toBe(true)
231
-
232
-
if ('entries' in cssDir!.node) {
233
-
expect(cssDir!.node.entries).toHaveLength(1)
234
-
expect(cssDir!.node.entries[0].name).toBe('styles.css')
235
-
}
236
-
237
-
// Check js directory exists
238
-
const jsDir = result.directory.entries.find(e => e.name === 'js')
239
-
expect(jsDir).toBeDefined()
240
-
expect('entries' in jsDir!.node).toBe(true)
241
-
})
242
-
243
-
test('should handle deeply nested subdirectories', () => {
244
-
const files: UploadedFile[] = [
245
-
{
246
-
name: 'dist/deep/nested/folder/file.txt',
247
-
content: Buffer.from('content'),
248
-
mimeType: 'text/plain',
249
-
size: 7,
250
-
},
251
-
]
252
-
253
-
const result = processUploadedFiles(files)
254
-
255
-
expect(result.fileCount).toBe(1)
256
-
257
-
// Navigate through the directory structure (base folder removed)
258
-
const deepDir = result.directory.entries.find(e => e.name === 'deep')
259
-
expect(deepDir).toBeDefined()
260
-
expect('entries' in deepDir!.node).toBe(true)
261
-
262
-
if ('entries' in deepDir!.node) {
263
-
const nestedDir = deepDir!.node.entries.find(e => e.name === 'nested')
264
-
expect(nestedDir).toBeDefined()
265
-
266
-
if (nestedDir && 'entries' in nestedDir.node) {
267
-
const folderDir = nestedDir.node.entries.find(e => e.name === 'folder')
268
-
expect(folderDir).toBeDefined()
269
-
270
-
if (folderDir && 'entries' in folderDir.node) {
271
-
expect(folderDir.node.entries).toHaveLength(1)
272
-
expect(folderDir.node.entries[0].name).toBe('file.txt')
273
-
}
274
-
}
275
-
}
276
-
})
277
-
278
-
test('should remove base folder name from paths', () => {
279
-
const files: UploadedFile[] = [
280
-
{
281
-
name: 'dist/index.html',
282
-
content: Buffer.from('<html></html>'),
283
-
mimeType: 'text/html',
284
-
size: 13,
285
-
},
286
-
{
287
-
name: 'dist/css/styles.css',
288
-
content: Buffer.from('body {}'),
289
-
mimeType: 'text/css',
290
-
size: 7,
291
-
},
292
-
]
293
-
294
-
const result = processUploadedFiles(files)
295
-
296
-
// After removing 'dist/', we should have index.html and css/ at root
297
-
expect(result.directory.entries.find(e => e.name === 'index.html')).toBeDefined()
298
-
expect(result.directory.entries.find(e => e.name === 'css')).toBeDefined()
299
-
expect(result.directory.entries.find(e => e.name === 'dist')).toBeUndefined()
300
-
})
301
-
302
-
test('should handle empty file list', () => {
303
-
const files: UploadedFile[] = []
304
-
const result = processUploadedFiles(files)
305
-
306
-
expect(result.fileCount).toBe(0)
307
-
expect(result.directory.entries).toHaveLength(0)
308
-
})
309
-
310
-
test('should handle multiple files in same subdirectory', () => {
311
-
const files: UploadedFile[] = [
312
-
{
313
-
name: 'dist/assets/image1.png',
314
-
content: Buffer.from('png1'),
315
-
mimeType: 'image/png',
316
-
size: 4,
317
-
},
318
-
{
319
-
name: 'dist/assets/image2.png',
320
-
content: Buffer.from('png2'),
321
-
mimeType: 'image/png',
322
-
size: 4,
323
-
},
324
-
]
325
-
326
-
const result = processUploadedFiles(files)
327
-
328
-
expect(result.fileCount).toBe(2)
329
-
330
-
const assetsDir = result.directory.entries.find(e => e.name === 'assets')
331
-
expect(assetsDir).toBeDefined()
332
-
333
-
if ('entries' in assetsDir!.node) {
334
-
expect(assetsDir!.node.entries).toHaveLength(2)
335
-
const names = assetsDir!.node.entries.map(e => e.name)
336
-
expect(names).toContain('image1.png')
337
-
expect(names).toContain('image2.png')
338
-
}
339
-
})
340
-
})
341
-
342
-
describe('createManifest', () => {
343
-
test('should create valid manifest', () => {
344
-
const root: Directory = {
345
-
$type: 'place.wisp.fs#directory',
346
-
type: 'directory',
347
-
entries: [],
348
-
}
349
-
350
-
const manifest = createManifest('example.com', root, 0)
351
-
352
-
expect(manifest.$type).toBe('place.wisp.fs')
353
-
expect(manifest.site).toBe('example.com')
354
-
expect(manifest.root).toBe(root)
355
-
expect(manifest.fileCount).toBe(0)
356
-
expect(manifest.createdAt).toBeDefined()
357
-
358
-
// Verify it's a valid ISO date string
359
-
const date = new Date(manifest.createdAt)
360
-
expect(date.toISOString()).toBe(manifest.createdAt)
361
-
})
362
-
363
-
test('should create manifest with file count', () => {
364
-
const root: Directory = {
365
-
$type: 'place.wisp.fs#directory',
366
-
type: 'directory',
367
-
entries: [],
368
-
}
369
-
370
-
const manifest = createManifest('test-site', root, 42)
371
-
372
-
expect(manifest.fileCount).toBe(42)
373
-
expect(manifest.site).toBe('test-site')
374
-
})
375
-
376
-
test('should create manifest with populated directory', () => {
377
-
const mockBlob = createMockBlobRef('text/html', 100)
378
-
379
-
const root: Directory = {
380
-
$type: 'place.wisp.fs#directory',
381
-
type: 'directory',
382
-
entries: [
383
-
{
384
-
name: 'index.html',
385
-
node: {
386
-
$type: 'place.wisp.fs#file',
387
-
type: 'file',
388
-
blob: mockBlob,
389
-
},
390
-
},
391
-
],
392
-
}
393
-
394
-
const manifest = createManifest('populated-site', root, 1)
395
-
396
-
expect(manifest).toBeDefined()
397
-
expect(manifest.site).toBe('populated-site')
398
-
expect(manifest.root.entries).toHaveLength(1)
399
-
})
400
-
})
401
-
402
-
describe('updateFileBlobs', () => {
403
-
test('should update single file blob at root', () => {
404
-
const directory: Directory = {
405
-
$type: 'place.wisp.fs#directory',
406
-
type: 'directory',
407
-
entries: [
408
-
{
409
-
name: 'index.html',
410
-
node: {
411
-
$type: 'place.wisp.fs#file',
412
-
type: 'file',
413
-
blob: undefined as any,
414
-
},
415
-
},
416
-
],
417
-
}
418
-
419
-
const mockBlob = createMockBlobRef('text/html', 100)
420
-
const uploadResults: FileUploadResult[] = [
421
-
{
422
-
hash: TEST_CID_STRING,
423
-
blobRef: mockBlob,
424
-
mimeType: 'text/html',
425
-
},
426
-
]
427
-
428
-
const filePaths = ['index.html']
429
-
430
-
const updated = updateFileBlobs(directory, uploadResults, filePaths)
431
-
432
-
expect(updated.entries).toHaveLength(1)
433
-
const fileNode = updated.entries[0].node
434
-
435
-
if ('blob' in fileNode) {
436
-
expect(fileNode.blob).toBeDefined()
437
-
expect(fileNode.blob.mimeType).toBe('text/html')
438
-
expect(fileNode.blob.size).toBe(100)
439
-
} else {
440
-
throw new Error('Expected file node')
441
-
}
442
-
})
443
-
444
-
test('should update files in nested directories', () => {
445
-
const directory: Directory = {
446
-
$type: 'place.wisp.fs#directory',
447
-
type: 'directory',
448
-
entries: [
449
-
{
450
-
name: 'css',
451
-
node: {
452
-
$type: 'place.wisp.fs#directory',
453
-
type: 'directory',
454
-
entries: [
455
-
{
456
-
name: 'styles.css',
457
-
node: {
458
-
$type: 'place.wisp.fs#file',
459
-
type: 'file',
460
-
blob: undefined as any,
461
-
},
462
-
},
463
-
],
464
-
},
465
-
},
466
-
],
467
-
}
468
-
469
-
const mockBlob = createMockBlobRef('text/css', 50)
470
-
const uploadResults: FileUploadResult[] = [
471
-
{
472
-
hash: TEST_CID_STRING,
473
-
blobRef: mockBlob,
474
-
mimeType: 'text/css',
475
-
encoding: 'gzip',
476
-
},
477
-
]
478
-
479
-
const filePaths = ['css/styles.css']
480
-
481
-
const updated = updateFileBlobs(directory, uploadResults, filePaths)
482
-
483
-
const cssDir = updated.entries[0]
484
-
expect(cssDir.name).toBe('css')
485
-
486
-
if ('entries' in cssDir.node) {
487
-
const cssFile = cssDir.node.entries[0]
488
-
expect(cssFile.name).toBe('styles.css')
489
-
490
-
if ('blob' in cssFile.node) {
491
-
expect(cssFile.node.blob.mimeType).toBe('text/css')
492
-
if ('encoding' in cssFile.node) {
493
-
expect(cssFile.node.encoding).toBe('gzip')
494
-
}
495
-
} else {
496
-
throw new Error('Expected file node')
497
-
}
498
-
} else {
499
-
throw new Error('Expected directory node')
500
-
}
501
-
})
502
-
503
-
test('should handle normalized paths with base folder removed', () => {
504
-
const directory: Directory = {
505
-
$type: 'place.wisp.fs#directory',
506
-
type: 'directory',
507
-
entries: [
508
-
{
509
-
name: 'index.html',
510
-
node: {
511
-
$type: 'place.wisp.fs#file',
512
-
type: 'file',
513
-
blob: undefined as any,
514
-
},
515
-
},
516
-
],
517
-
}
518
-
519
-
const mockBlob = createMockBlobRef('text/html', 100)
520
-
const uploadResults: FileUploadResult[] = [
521
-
{
522
-
hash: TEST_CID_STRING,
523
-
blobRef: mockBlob,
524
-
},
525
-
]
526
-
527
-
// Path includes base folder that should be normalized
528
-
const filePaths = ['dist/index.html']
529
-
530
-
const updated = updateFileBlobs(directory, uploadResults, filePaths)
531
-
532
-
const fileNode = updated.entries[0].node
533
-
if ('blob' in fileNode) {
534
-
expect(fileNode.blob).toBeDefined()
535
-
} else {
536
-
throw new Error('Expected file node')
537
-
}
538
-
})
539
-
540
-
test('should preserve file metadata (encoding, mimeType, base64)', () => {
541
-
const directory: Directory = {
542
-
$type: 'place.wisp.fs#directory',
543
-
type: 'directory',
544
-
entries: [
545
-
{
546
-
name: 'data.json',
547
-
node: {
548
-
$type: 'place.wisp.fs#file',
549
-
type: 'file',
550
-
blob: undefined as any,
551
-
},
552
-
},
553
-
],
554
-
}
555
-
556
-
const mockBlob = createMockBlobRef('application/json', 200)
557
-
const uploadResults: FileUploadResult[] = [
558
-
{
559
-
hash: TEST_CID_STRING,
560
-
blobRef: mockBlob,
561
-
mimeType: 'application/json',
562
-
encoding: 'gzip',
563
-
base64: true,
564
-
},
565
-
]
566
-
567
-
const filePaths = ['data.json']
568
-
569
-
const updated = updateFileBlobs(directory, uploadResults, filePaths)
570
-
571
-
const fileNode = updated.entries[0].node
572
-
if ('blob' in fileNode && 'mimeType' in fileNode && 'encoding' in fileNode && 'base64' in fileNode) {
573
-
expect(fileNode.mimeType).toBe('application/json')
574
-
expect(fileNode.encoding).toBe('gzip')
575
-
expect(fileNode.base64).toBe(true)
576
-
} else {
577
-
throw new Error('Expected file node with metadata')
578
-
}
579
-
})
580
-
581
-
test('should handle multiple files at different directory levels', () => {
582
-
const directory: Directory = {
583
-
$type: 'place.wisp.fs#directory',
584
-
type: 'directory',
585
-
entries: [
586
-
{
587
-
name: 'index.html',
588
-
node: {
589
-
$type: 'place.wisp.fs#file',
590
-
type: 'file',
591
-
blob: undefined as any,
592
-
},
593
-
},
594
-
{
595
-
name: 'assets',
596
-
node: {
597
-
$type: 'place.wisp.fs#directory',
598
-
type: 'directory',
599
-
entries: [
600
-
{
601
-
name: 'logo.svg',
602
-
node: {
603
-
$type: 'place.wisp.fs#file',
604
-
type: 'file',
605
-
blob: undefined as any,
606
-
},
607
-
},
608
-
],
609
-
},
610
-
},
611
-
],
612
-
}
613
-
614
-
const htmlBlob = createMockBlobRef('text/html', 100)
615
-
const svgBlob = createMockBlobRef('image/svg+xml', 500)
616
-
617
-
const uploadResults: FileUploadResult[] = [
618
-
{
619
-
hash: TEST_CID_STRING,
620
-
blobRef: htmlBlob,
621
-
},
622
-
{
623
-
hash: TEST_CID_STRING,
624
-
blobRef: svgBlob,
625
-
},
626
-
]
627
-
628
-
const filePaths = ['index.html', 'assets/logo.svg']
629
-
630
-
const updated = updateFileBlobs(directory, uploadResults, filePaths)
631
-
632
-
// Check root file
633
-
const indexNode = updated.entries[0].node
634
-
if ('blob' in indexNode) {
635
-
expect(indexNode.blob.mimeType).toBe('text/html')
636
-
}
637
-
638
-
// Check nested file
639
-
const assetsDir = updated.entries[1]
640
-
if ('entries' in assetsDir.node) {
641
-
const logoNode = assetsDir.node.entries[0].node
642
-
if ('blob' in logoNode) {
643
-
expect(logoNode.blob.mimeType).toBe('image/svg+xml')
644
-
}
645
-
}
646
-
})
647
-
})
648
-
649
-
describe('computeCID', () => {
650
-
test('should compute CID for gzipped+base64 encoded content', () => {
651
-
// This simulates the actual flow: gzip -> base64 -> compute CID
652
-
const originalContent = Buffer.from('Hello, World!')
653
-
const gzipped = compressFile(originalContent)
654
-
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
655
-
656
-
const cid = computeCID(base64Content)
657
-
658
-
// CID should be a valid CIDv1 string starting with 'bafkrei'
659
-
expect(cid).toMatch(/^bafkrei[a-z0-9]+$/)
660
-
expect(cid.length).toBeGreaterThan(10)
661
-
})
662
-
663
-
test('should compute deterministic CIDs for identical content', () => {
664
-
const content = Buffer.from('Test content for CID calculation')
665
-
const gzipped = compressFile(content)
666
-
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
667
-
668
-
const cid1 = computeCID(base64Content)
669
-
const cid2 = computeCID(base64Content)
670
-
671
-
expect(cid1).toBe(cid2)
672
-
})
673
-
674
-
test('should compute different CIDs for different content', () => {
675
-
const content1 = Buffer.from('Content A')
676
-
const content2 = Buffer.from('Content B')
677
-
678
-
const gzipped1 = compressFile(content1)
679
-
const gzipped2 = compressFile(content2)
680
-
681
-
const base64Content1 = Buffer.from(gzipped1.toString('base64'), 'binary')
682
-
const base64Content2 = Buffer.from(gzipped2.toString('base64'), 'binary')
683
-
684
-
const cid1 = computeCID(base64Content1)
685
-
const cid2 = computeCID(base64Content2)
686
-
687
-
expect(cid1).not.toBe(cid2)
688
-
})
689
-
690
-
test('should handle empty content', () => {
691
-
const emptyContent = Buffer.from('')
692
-
const gzipped = compressFile(emptyContent)
693
-
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
694
-
695
-
const cid = computeCID(base64Content)
696
-
697
-
expect(cid).toMatch(/^bafkrei[a-z0-9]+$/)
698
-
})
699
-
700
-
test('should compute same CID as PDS for base64-encoded content', () => {
701
-
// Test that binary encoding produces correct bytes for CID calculation
702
-
const testContent = Buffer.from('<!DOCTYPE html><html><body>Hello</body></html>')
703
-
const gzipped = compressFile(testContent)
704
-
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
705
-
706
-
// Compute CID twice to ensure consistency
707
-
const cid1 = computeCID(base64Content)
708
-
const cid2 = computeCID(base64Content)
709
-
710
-
expect(cid1).toBe(cid2)
711
-
expect(cid1).toMatch(/^bafkrei/)
712
-
})
713
-
714
-
test('should use binary encoding for base64 strings', () => {
715
-
// This test verifies we're using the correct encoding method
716
-
// For base64 strings, 'binary' encoding ensures each character becomes exactly one byte
717
-
const content = Buffer.from('Test content')
718
-
const gzipped = compressFile(content)
719
-
const base64String = gzipped.toString('base64')
720
-
721
-
// Using binary encoding (what we use in production)
722
-
const base64Content = Buffer.from(base64String, 'binary')
723
-
724
-
// Verify the length matches the base64 string length
725
-
expect(base64Content.length).toBe(base64String.length)
726
-
727
-
// Verify CID is computed correctly
728
-
const cid = computeCID(base64Content)
729
-
expect(cid).toMatch(/^bafkrei/)
730
-
})
731
-
})
732
-
733
-
describe('extractBlobMap', () => {
734
-
test('should extract blob map from flat directory structure', () => {
735
-
const mockCid = CID.parse(TEST_CID_STRING)
736
-
const mockBlob = new BlobRef(mockCid as any, 'text/html', 100)
737
-
738
-
const directory: Directory = {
739
-
$type: 'place.wisp.fs#directory',
740
-
type: 'directory',
741
-
entries: [
742
-
{
743
-
name: 'index.html',
744
-
node: {
745
-
$type: 'place.wisp.fs#file',
746
-
type: 'file',
747
-
blob: mockBlob,
748
-
},
749
-
},
750
-
],
751
-
}
752
-
753
-
const blobMap = extractBlobMap(directory)
754
-
755
-
expect(blobMap.size).toBe(1)
756
-
expect(blobMap.has('index.html')).toBe(true)
757
-
758
-
const entry = blobMap.get('index.html')
759
-
expect(entry?.cid).toBe(TEST_CID_STRING)
760
-
expect(entry?.blobRef).toBe(mockBlob)
761
-
})
762
-
763
-
test('should extract blob map from nested directory structure', () => {
764
-
const mockCid1 = CID.parse(TEST_CID_STRING)
765
-
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
766
-
767
-
const mockBlob1 = new BlobRef(mockCid1 as any, 'text/html', 100)
768
-
const mockBlob2 = new BlobRef(mockCid2 as any, 'text/css', 50)
769
-
770
-
const directory: Directory = {
771
-
$type: 'place.wisp.fs#directory',
772
-
type: 'directory',
773
-
entries: [
774
-
{
775
-
name: 'index.html',
776
-
node: {
777
-
$type: 'place.wisp.fs#file',
778
-
type: 'file',
779
-
blob: mockBlob1,
780
-
},
781
-
},
782
-
{
783
-
name: 'assets',
784
-
node: {
785
-
$type: 'place.wisp.fs#directory',
786
-
type: 'directory',
787
-
entries: [
788
-
{
789
-
name: 'styles.css',
790
-
node: {
791
-
$type: 'place.wisp.fs#file',
792
-
type: 'file',
793
-
blob: mockBlob2,
794
-
},
795
-
},
796
-
],
797
-
},
798
-
},
799
-
],
800
-
}
801
-
802
-
const blobMap = extractBlobMap(directory)
803
-
804
-
expect(blobMap.size).toBe(2)
805
-
expect(blobMap.has('index.html')).toBe(true)
806
-
expect(blobMap.has('assets/styles.css')).toBe(true)
807
-
808
-
expect(blobMap.get('index.html')?.cid).toBe(TEST_CID_STRING)
809
-
expect(blobMap.get('assets/styles.css')?.cid).toBe('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
810
-
})
811
-
812
-
test('should handle deeply nested directory structures', () => {
813
-
const mockCid = CID.parse(TEST_CID_STRING)
814
-
const mockBlob = new BlobRef(mockCid as any, 'text/javascript', 200)
815
-
816
-
const directory: Directory = {
817
-
$type: 'place.wisp.fs#directory',
818
-
type: 'directory',
819
-
entries: [
820
-
{
821
-
name: 'src',
822
-
node: {
823
-
$type: 'place.wisp.fs#directory',
824
-
type: 'directory',
825
-
entries: [
826
-
{
827
-
name: 'lib',
828
-
node: {
829
-
$type: 'place.wisp.fs#directory',
830
-
type: 'directory',
831
-
entries: [
832
-
{
833
-
name: 'utils.js',
834
-
node: {
835
-
$type: 'place.wisp.fs#file',
836
-
type: 'file',
837
-
blob: mockBlob,
838
-
},
839
-
},
840
-
],
841
-
},
842
-
},
843
-
],
844
-
},
845
-
},
846
-
],
847
-
}
848
-
849
-
const blobMap = extractBlobMap(directory)
850
-
851
-
expect(blobMap.size).toBe(1)
852
-
expect(blobMap.has('src/lib/utils.js')).toBe(true)
853
-
expect(blobMap.get('src/lib/utils.js')?.cid).toBe(TEST_CID_STRING)
854
-
})
855
-
856
-
test('should handle empty directory', () => {
857
-
const directory: Directory = {
858
-
$type: 'place.wisp.fs#directory',
859
-
type: 'directory',
860
-
entries: [],
861
-
}
862
-
863
-
const blobMap = extractBlobMap(directory)
864
-
865
-
expect(blobMap.size).toBe(0)
866
-
})
867
-
868
-
test('should correctly extract CID from BlobRef instances (not plain objects)', () => {
869
-
// This test verifies the fix: AT Protocol SDK returns BlobRef instances,
870
-
// not plain objects with $type and $link properties
871
-
const mockCid = CID.parse(TEST_CID_STRING)
872
-
const mockBlob = new BlobRef(mockCid as any, 'application/octet-stream', 500)
873
-
874
-
const directory: Directory = {
875
-
$type: 'place.wisp.fs#directory',
876
-
type: 'directory',
877
-
entries: [
878
-
{
879
-
name: 'test.bin',
880
-
node: {
881
-
$type: 'place.wisp.fs#file',
882
-
type: 'file',
883
-
blob: mockBlob,
884
-
},
885
-
},
886
-
],
887
-
}
888
-
889
-
const blobMap = extractBlobMap(directory)
890
-
891
-
// The fix: we call .toString() on the CID instance instead of accessing $link
892
-
expect(blobMap.get('test.bin')?.cid).toBe(TEST_CID_STRING)
893
-
expect(blobMap.get('test.bin')?.blobRef.ref.toString()).toBe(TEST_CID_STRING)
894
-
})
895
-
896
-
test('should handle multiple files in same directory', () => {
897
-
const mockCid1 = CID.parse(TEST_CID_STRING)
898
-
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
899
-
const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq')
900
-
901
-
const mockBlob1 = new BlobRef(mockCid1 as any, 'image/png', 1000)
902
-
const mockBlob2 = new BlobRef(mockCid2 as any, 'image/png', 2000)
903
-
const mockBlob3 = new BlobRef(mockCid3 as any, 'image/png', 3000)
904
-
905
-
const directory: Directory = {
906
-
$type: 'place.wisp.fs#directory',
907
-
type: 'directory',
908
-
entries: [
909
-
{
910
-
name: 'images',
911
-
node: {
912
-
$type: 'place.wisp.fs#directory',
913
-
type: 'directory',
914
-
entries: [
915
-
{
916
-
name: 'logo.png',
917
-
node: {
918
-
$type: 'place.wisp.fs#file',
919
-
type: 'file',
920
-
blob: mockBlob1,
921
-
},
922
-
},
923
-
{
924
-
name: 'banner.png',
925
-
node: {
926
-
$type: 'place.wisp.fs#file',
927
-
type: 'file',
928
-
blob: mockBlob2,
929
-
},
930
-
},
931
-
{
932
-
name: 'icon.png',
933
-
node: {
934
-
$type: 'place.wisp.fs#file',
935
-
type: 'file',
936
-
blob: mockBlob3,
937
-
},
938
-
},
939
-
],
940
-
},
941
-
},
942
-
],
943
-
}
944
-
945
-
const blobMap = extractBlobMap(directory)
946
-
947
-
expect(blobMap.size).toBe(3)
948
-
expect(blobMap.has('images/logo.png')).toBe(true)
949
-
expect(blobMap.has('images/banner.png')).toBe(true)
950
-
expect(blobMap.has('images/icon.png')).toBe(true)
951
-
})
952
-
953
-
test('should handle mixed directory and file structure', () => {
954
-
const mockCid1 = CID.parse(TEST_CID_STRING)
955
-
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
956
-
const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq')
957
-
958
-
const directory: Directory = {
959
-
$type: 'place.wisp.fs#directory',
960
-
type: 'directory',
961
-
entries: [
962
-
{
963
-
name: 'index.html',
964
-
node: {
965
-
$type: 'place.wisp.fs#file',
966
-
type: 'file',
967
-
blob: new BlobRef(mockCid1 as any, 'text/html', 100),
968
-
},
969
-
},
970
-
{
971
-
name: 'assets',
972
-
node: {
973
-
$type: 'place.wisp.fs#directory',
974
-
type: 'directory',
975
-
entries: [
976
-
{
977
-
name: 'styles.css',
978
-
node: {
979
-
$type: 'place.wisp.fs#file',
980
-
type: 'file',
981
-
blob: new BlobRef(mockCid2 as any, 'text/css', 50),
982
-
},
983
-
},
984
-
],
985
-
},
986
-
},
987
-
{
988
-
name: 'README.md',
989
-
node: {
990
-
$type: 'place.wisp.fs#file',
991
-
type: 'file',
992
-
blob: new BlobRef(mockCid3 as any, 'text/markdown', 200),
993
-
},
994
-
},
995
-
],
996
-
}
997
-
998
-
const blobMap = extractBlobMap(directory)
999
-
1000
-
expect(blobMap.size).toBe(3)
1001
-
expect(blobMap.has('index.html')).toBe(true)
1002
-
expect(blobMap.has('assets/styles.css')).toBe(true)
1003
-
expect(blobMap.has('README.md')).toBe(true)
1004
-
})
1005
-
})
-470
src/lib/wisp-utils.ts
-470
src/lib/wisp-utils.ts
···
1
-
import type { BlobRef } from "@atproto/api";
2
-
import type { Record, Directory, File, Entry } from "../lexicons/types/place/wisp/fs";
3
-
import { validateRecord } from "../lexicons/types/place/wisp/fs";
4
-
import { gzipSync } from 'zlib';
5
-
import { CID } from 'multiformats/cid';
6
-
import { sha256 } from 'multiformats/hashes/sha2';
7
-
import * as raw from 'multiformats/codecs/raw';
8
-
import { createHash } from 'crypto';
9
-
import * as mf from 'multiformats';
10
-
11
-
export interface UploadedFile {
12
-
name: string;
13
-
content: Buffer;
14
-
mimeType: string;
15
-
size: number;
16
-
compressed?: boolean;
17
-
base64Encoded?: boolean;
18
-
originalMimeType?: string;
19
-
}
20
-
21
-
export interface FileUploadResult {
22
-
hash: string;
23
-
blobRef: BlobRef;
24
-
encoding?: 'gzip';
25
-
mimeType?: string;
26
-
base64?: boolean;
27
-
}
28
-
29
-
export interface ProcessedDirectory {
30
-
directory: Directory;
31
-
fileCount: number;
32
-
}
33
-
34
-
/**
35
-
* Determine if a file should be gzip compressed based on its MIME type and filename
36
-
*/
37
-
export function shouldCompressFile(mimeType: string, fileName?: string): boolean {
38
-
// Never compress _redirects file - it needs to be plain text for the hosting service
39
-
if (fileName && (fileName.endsWith('/_redirects') || fileName === '_redirects')) {
40
-
return false;
41
-
}
42
-
43
-
// Compress text-based files and uncompressed audio formats
44
-
const compressibleTypes = [
45
-
'text/html',
46
-
'text/css',
47
-
'text/javascript',
48
-
'application/javascript',
49
-
'application/json',
50
-
'image/svg+xml',
51
-
'text/xml',
52
-
'application/xml',
53
-
'text/plain',
54
-
'application/x-javascript',
55
-
// Uncompressed audio formats (WAV, AIFF, etc.)
56
-
'audio/wav',
57
-
'audio/wave',
58
-
'audio/x-wav',
59
-
'audio/aiff',
60
-
'audio/x-aiff'
61
-
];
62
-
63
-
// Check if mime type starts with any compressible type
64
-
return compressibleTypes.some(type => mimeType.startsWith(type));
65
-
}
66
-
67
-
/**
68
-
* Compress a file using gzip with deterministic output
69
-
*/
70
-
export function compressFile(content: Buffer): Buffer {
71
-
return gzipSync(content, {
72
-
level: 9
73
-
});
74
-
}
75
-
76
-
/**
77
-
* Process uploaded files into a directory structure
78
-
*/
79
-
export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory {
80
-
const entries: Entry[] = [];
81
-
let fileCount = 0;
82
-
83
-
// Group files by directory
84
-
const directoryMap = new Map<string, UploadedFile[]>();
85
-
86
-
for (const file of files) {
87
-
// Skip undefined/null files (defensive)
88
-
if (!file || !file.name) {
89
-
console.error('Skipping undefined or invalid file in processUploadedFiles');
90
-
continue;
91
-
}
92
-
93
-
// Remove any base folder name from the path
94
-
const normalizedPath = file.name.replace(/^[^\/]*\//, '');
95
-
96
-
// Skip files in .git directories
97
-
if (normalizedPath.startsWith('.git/') || normalizedPath === '.git') {
98
-
continue;
99
-
}
100
-
101
-
const parts = normalizedPath.split('/');
102
-
103
-
if (parts.length === 1) {
104
-
// Root level file
105
-
entries.push({
106
-
name: parts[0],
107
-
node: {
108
-
$type: 'place.wisp.fs#file' as const,
109
-
type: 'file' as const,
110
-
blob: undefined as any // Will be filled after upload
111
-
}
112
-
});
113
-
fileCount++;
114
-
} else {
115
-
// File in subdirectory
116
-
const dirPath = parts.slice(0, -1).join('/');
117
-
if (!directoryMap.has(dirPath)) {
118
-
directoryMap.set(dirPath, []);
119
-
}
120
-
directoryMap.get(dirPath)!.push({
121
-
...file,
122
-
name: normalizedPath
123
-
});
124
-
}
125
-
}
126
-
127
-
// Process subdirectories
128
-
for (const [dirPath, dirFiles] of directoryMap) {
129
-
const dirEntries: Entry[] = [];
130
-
131
-
for (const file of dirFiles) {
132
-
const fileName = file.name.split('/').pop()!;
133
-
dirEntries.push({
134
-
name: fileName,
135
-
node: {
136
-
$type: 'place.wisp.fs#file' as const,
137
-
type: 'file' as const,
138
-
blob: undefined as any // Will be filled after upload
139
-
}
140
-
});
141
-
fileCount++;
142
-
}
143
-
144
-
// Build nested directory structure
145
-
const pathParts = dirPath.split('/');
146
-
let currentEntries = entries;
147
-
148
-
for (let i = 0; i < pathParts.length; i++) {
149
-
const part = pathParts[i];
150
-
const isLast = i === pathParts.length - 1;
151
-
152
-
let existingEntry = currentEntries.find(e => e.name === part);
153
-
154
-
if (!existingEntry) {
155
-
const newDir = {
156
-
$type: 'place.wisp.fs#directory' as const,
157
-
type: 'directory' as const,
158
-
entries: isLast ? dirEntries : []
159
-
};
160
-
161
-
existingEntry = {
162
-
name: part,
163
-
node: newDir
164
-
};
165
-
currentEntries.push(existingEntry);
166
-
} else if ('entries' in existingEntry.node && isLast) {
167
-
(existingEntry.node as any).entries.push(...dirEntries);
168
-
}
169
-
170
-
if (existingEntry && 'entries' in existingEntry.node) {
171
-
currentEntries = (existingEntry.node as any).entries;
172
-
}
173
-
}
174
-
}
175
-
176
-
const result = {
177
-
directory: {
178
-
$type: 'place.wisp.fs#directory' as const,
179
-
type: 'directory' as const,
180
-
entries
181
-
},
182
-
fileCount
183
-
};
184
-
185
-
return result;
186
-
}
187
-
188
-
/**
189
-
* Create the manifest record for a site
190
-
*/
191
-
export function createManifest(
192
-
siteName: string,
193
-
root: Directory,
194
-
fileCount: number
195
-
): Record {
196
-
const manifest = {
197
-
$type: 'place.wisp.fs' as const,
198
-
site: siteName,
199
-
root,
200
-
fileCount,
201
-
createdAt: new Date().toISOString()
202
-
};
203
-
204
-
// Validate the manifest before returning
205
-
const validationResult = validateRecord(manifest);
206
-
if (!validationResult.success) {
207
-
throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`);
208
-
}
209
-
210
-
return manifest;
211
-
}
212
-
213
-
/**
214
-
* Update file blobs in directory structure after upload
215
-
* Uses path-based matching to correctly match files in nested directories
216
-
* Filters out files that were not successfully uploaded
217
-
*/
218
-
export function updateFileBlobs(
219
-
directory: Directory,
220
-
uploadResults: FileUploadResult[],
221
-
filePaths: string[],
222
-
currentPath: string = '',
223
-
successfulPaths?: Set<string>
224
-
): Directory {
225
-
const updatedEntries = directory.entries.map(entry => {
226
-
if ('type' in entry.node && entry.node.type === 'file') {
227
-
// Build the full path for this file
228
-
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
229
-
230
-
// If successfulPaths is provided, skip files that weren't successfully uploaded
231
-
if (successfulPaths && !successfulPaths.has(fullPath)) {
232
-
return null; // Filter out failed files
233
-
}
234
-
235
-
// Find exact match in filePaths (need to handle normalized paths)
236
-
const fileIndex = filePaths.findIndex((path) => {
237
-
// Normalize both paths by removing leading base folder
238
-
const normalizedUploadPath = path.replace(/^[^\/]*\//, '');
239
-
const normalizedEntryPath = fullPath;
240
-
return normalizedUploadPath === normalizedEntryPath || path === fullPath;
241
-
});
242
-
243
-
if (fileIndex !== -1 && uploadResults[fileIndex]) {
244
-
const result = uploadResults[fileIndex];
245
-
const blobRef = result.blobRef;
246
-
247
-
return {
248
-
...entry,
249
-
node: {
250
-
$type: 'place.wisp.fs#file' as const,
251
-
type: 'file' as const,
252
-
blob: blobRef,
253
-
...(result.encoding && { encoding: result.encoding }),
254
-
...(result.mimeType && { mimeType: result.mimeType }),
255
-
...(result.base64 && { base64: result.base64 })
256
-
}
257
-
};
258
-
} else {
259
-
console.error(`Could not find blob for file: ${fullPath}`);
260
-
return null; // Filter out files without blobs
261
-
}
262
-
} else if ('type' in entry.node && entry.node.type === 'directory') {
263
-
const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
264
-
return {
265
-
...entry,
266
-
node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths, dirPath, successfulPaths)
267
-
};
268
-
}
269
-
return entry;
270
-
}).filter(entry => entry !== null) as Entry[]; // Remove null entries (failed files)
271
-
272
-
const result = {
273
-
$type: 'place.wisp.fs#directory' as const,
274
-
type: 'directory' as const,
275
-
entries: updatedEntries
276
-
};
277
-
278
-
return result;
279
-
}
280
-
281
-
/**
282
-
* Compute CID (Content Identifier) for blob content
283
-
* Uses the same algorithm as AT Protocol: CIDv1 with raw codec and SHA-256
284
-
* Based on @atproto/common/src/ipld.ts sha256RawToCid implementation
285
-
*/
286
-
export function computeCID(content: Buffer): string {
287
-
// Use node crypto to compute sha256 hash (same as AT Protocol)
288
-
const hash = createHash('sha256').update(content).digest();
289
-
// Create digest object from hash bytes
290
-
const digest = mf.digest.create(sha256.code, hash);
291
-
// Create CIDv1 with raw codec
292
-
const cid = CID.createV1(raw.code, digest);
293
-
return cid.toString();
294
-
}
295
-
296
-
/**
297
-
* Extract blob information from a directory tree
298
-
* Returns a map of file paths to their blob refs and CIDs
299
-
*/
300
-
export function extractBlobMap(
301
-
directory: Directory,
302
-
currentPath: string = ''
303
-
): Map<string, { blobRef: BlobRef; cid: string }> {
304
-
const blobMap = new Map<string, { blobRef: BlobRef; cid: string }>();
305
-
306
-
for (const entry of directory.entries) {
307
-
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
308
-
309
-
if ('type' in entry.node && entry.node.type === 'file') {
310
-
const fileNode = entry.node as File;
311
-
// AT Protocol SDK returns BlobRef class instances, not plain objects
312
-
// The ref is a CID instance that can be converted to string
313
-
if (fileNode.blob && fileNode.blob.ref) {
314
-
const cidString = fileNode.blob.ref.toString();
315
-
blobMap.set(fullPath, {
316
-
blobRef: fileNode.blob,
317
-
cid: cidString
318
-
});
319
-
}
320
-
} else if ('type' in entry.node && entry.node.type === 'directory') {
321
-
const subMap = extractBlobMap(entry.node as Directory, fullPath);
322
-
subMap.forEach((value, key) => blobMap.set(key, value));
323
-
}
324
-
// Skip subfs nodes - they don't contain blobs in the main tree
325
-
}
326
-
327
-
return blobMap;
328
-
}
329
-
330
-
/**
331
-
* Extract all subfs URIs from a directory tree with their mount paths
332
-
*/
333
-
export function extractSubfsUris(
334
-
directory: Directory,
335
-
currentPath: string = ''
336
-
): Array<{ uri: string; path: string }> {
337
-
const uris: Array<{ uri: string; path: string }> = [];
338
-
339
-
for (const entry of directory.entries) {
340
-
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
341
-
342
-
if ('type' in entry.node) {
343
-
if (entry.node.type === 'subfs') {
344
-
// Subfs node with subject URI
345
-
const subfsNode = entry.node as any;
346
-
if (subfsNode.subject) {
347
-
uris.push({ uri: subfsNode.subject, path: fullPath });
348
-
}
349
-
} else if (entry.node.type === 'directory') {
350
-
// Recursively search subdirectories
351
-
const subUris = extractSubfsUris(entry.node as Directory, fullPath);
352
-
uris.push(...subUris);
353
-
}
354
-
}
355
-
}
356
-
357
-
return uris;
358
-
}
359
-
360
-
/**
361
-
* Estimate the JSON size of a directory tree
362
-
*/
363
-
export function estimateDirectorySize(directory: Directory): number {
364
-
return JSON.stringify(directory).length;
365
-
}
366
-
367
-
/**
368
-
* Count files in a directory tree
369
-
*/
370
-
export function countFilesInDirectory(directory: Directory): number {
371
-
let count = 0;
372
-
for (const entry of directory.entries) {
373
-
if ('type' in entry.node && entry.node.type === 'file') {
374
-
count++;
375
-
} else if ('type' in entry.node && entry.node.type === 'directory') {
376
-
count += countFilesInDirectory(entry.node as Directory);
377
-
}
378
-
}
379
-
return count;
380
-
}
381
-
382
-
/**
383
-
* Find all directories in a tree with their paths and sizes
384
-
*/
385
-
export function findLargeDirectories(directory: Directory, currentPath: string = ''): Array<{
386
-
path: string;
387
-
directory: Directory;
388
-
size: number;
389
-
fileCount: number;
390
-
}> {
391
-
const result: Array<{ path: string; directory: Directory; size: number; fileCount: number }> = [];
392
-
393
-
for (const entry of directory.entries) {
394
-
if ('type' in entry.node && entry.node.type === 'directory') {
395
-
const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
396
-
const dir = entry.node as Directory;
397
-
const size = estimateDirectorySize(dir);
398
-
const fileCount = countFilesInDirectory(dir);
399
-
400
-
result.push({ path: dirPath, directory: dir, size, fileCount });
401
-
402
-
// Recursively find subdirectories
403
-
const subdirs = findLargeDirectories(dir, dirPath);
404
-
result.push(...subdirs);
405
-
}
406
-
}
407
-
408
-
return result;
409
-
}
410
-
411
-
/**
412
-
* Replace a directory with a subfs node in the tree
413
-
*/
414
-
export function replaceDirectoryWithSubfs(
415
-
directory: Directory,
416
-
targetPath: string,
417
-
subfsUri: string
418
-
): Directory {
419
-
const pathParts = targetPath.split('/');
420
-
const targetName = pathParts[pathParts.length - 1];
421
-
const parentPath = pathParts.slice(0, -1).join('/');
422
-
423
-
// If this is a root-level directory
424
-
if (pathParts.length === 1) {
425
-
const newEntries = directory.entries.map(entry => {
426
-
if (entry.name === targetName && 'type' in entry.node && entry.node.type === 'directory') {
427
-
return {
428
-
name: entry.name,
429
-
node: {
430
-
$type: 'place.wisp.fs#subfs' as const,
431
-
type: 'subfs' as const,
432
-
subject: subfsUri,
433
-
flat: false // Preserve directory structure
434
-
}
435
-
};
436
-
}
437
-
return entry;
438
-
});
439
-
440
-
return {
441
-
$type: 'place.wisp.fs#directory' as const,
442
-
type: 'directory' as const,
443
-
entries: newEntries
444
-
};
445
-
}
446
-
447
-
// Recursively navigate to parent directory
448
-
const newEntries = directory.entries.map(entry => {
449
-
if ('type' in entry.node && entry.node.type === 'directory') {
450
-
const entryPath = entry.name;
451
-
if (parentPath.startsWith(entryPath) || parentPath === entry.name) {
452
-
const remainingPath = pathParts.slice(1).join('/');
453
-
return {
454
-
name: entry.name,
455
-
node: {
456
-
...replaceDirectoryWithSubfs(entry.node as Directory, remainingPath, subfsUri),
457
-
$type: 'place.wisp.fs#directory' as const
458
-
}
459
-
};
460
-
}
461
-
}
462
-
return entry;
463
-
});
464
-
465
-
return {
466
-
$type: 'place.wisp.fs#directory' as const,
467
-
type: 'directory' as const,
468
-
entries: newEntries
469
-
};
470
-
}
+1
-1
src/routes/admin.ts
apps/main-app/src/routes/admin.ts
+1
-1
src/routes/admin.ts
apps/main-app/src/routes/admin.ts
···
1
1
// Admin API routes
2
2
import { Elysia, t } from 'elysia'
3
3
import { adminAuth, requireAdmin } from '../lib/admin-auth'
4
-
import { logCollector, errorTracker, metricsCollector } from '../lib/observability'
4
+
import { logCollector, errorTracker, metricsCollector } from '@wisp/observability'
5
5
import { db } from '../lib/db'
6
6
7
7
export const adminRoutes = (cookieSecret: string) =>
+3
-1
src/routes/auth.ts
apps/main-app/src/routes/auth.ts
+3
-1
src/routes/auth.ts
apps/main-app/src/routes/auth.ts
···
3
3
import { getSitesByDid, getDomainByDid, getCookieSecret } from '../lib/db'
4
4
import { syncSitesFromPDS } from '../lib/sync-sites'
5
5
import { authenticateRequest } from '../lib/wisp-auth'
6
-
import { logger } from '../lib/observability'
6
+
import { createLogger } from '@wisp/observability'
7
+
8
+
const logger = createLogger('main-app')
7
9
8
10
export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({
9
11
cookie: {
+3
-1
src/routes/domain.ts
apps/main-app/src/routes/domain.ts
+3
-1
src/routes/domain.ts
apps/main-app/src/routes/domain.ts
···
22
22
} from '../lib/db'
23
23
import { createHash } from 'crypto'
24
24
import { verifyCustomDomain } from '../lib/dns-verify'
25
-
import { logger } from '../lib/logger'
25
+
import { createLogger } from '@wisp/observability'
26
+
27
+
const logger = createLogger('main-app')
26
28
27
29
export const domainRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
28
30
new Elysia({
+4
-2
src/routes/site.ts
apps/main-app/src/routes/site.ts
+4
-2
src/routes/site.ts
apps/main-app/src/routes/site.ts
···
3
3
import { NodeOAuthClient } from '@atproto/oauth-client-node'
4
4
import { Agent } from '@atproto/api'
5
5
import { deleteSite } from '../lib/db'
6
-
import { logger } from '../lib/logger'
7
-
import { extractSubfsUris } from '../lib/wisp-utils'
6
+
import { createLogger } from '@wisp/observability'
7
+
import { extractSubfsUris } from '@wisp/atproto-utils'
8
+
9
+
const logger = createLogger('main-app')
8
10
9
11
export const siteRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
10
12
new Elysia({
+4
-2
src/routes/user.ts
apps/main-app/src/routes/user.ts
+4
-2
src/routes/user.ts
apps/main-app/src/routes/user.ts
···
4
4
import { Agent } from '@atproto/api'
5
5
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite, getAllWispDomains } from '../lib/db'
6
6
import { syncSitesFromPDS } from '../lib/sync-sites'
7
-
import { logger } from '../lib/logger'
7
+
import { createLogger } from '@wisp/observability'
8
+
9
+
const logger = createLogger('main-app')
8
10
9
11
export const userRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
10
12
new Elysia({
···
96
98
})
97
99
.post('/sync', async ({ auth }) => {
98
100
try {
99
-
logger.debug('[User] Manual sync requested for', auth.did)
101
+
logger.debug('[User] Manual sync requested for', { did: auth.did })
100
102
const result = await syncSitesFromPDS(auth.did, auth.session)
101
103
102
104
return {
+14
-10
src/routes/wisp.ts
apps/main-app/src/routes/wisp.ts
+14
-10
src/routes/wisp.ts
apps/main-app/src/routes/wisp.ts
···
7
7
type UploadedFile,
8
8
type FileUploadResult,
9
9
processUploadedFiles,
10
-
createManifest,
11
10
updateFileBlobs,
11
+
findLargeDirectories,
12
+
replaceDirectoryWithSubfs,
13
+
estimateDirectorySize
14
+
} from '@wisp/fs-utils'
15
+
import {
12
16
shouldCompressFile,
13
17
compressFile,
14
18
computeCID,
15
19
extractBlobMap,
16
-
extractSubfsUris,
17
-
findLargeDirectories,
18
-
replaceDirectoryWithSubfs,
19
-
estimateDirectorySize
20
-
} from '../lib/wisp-utils'
20
+
extractSubfsUris
21
+
} from '@wisp/atproto-utils'
22
+
import { createManifest } from '@wisp/fs-utils'
21
23
import { upsertSite } from '../lib/db'
22
-
import { logger } from '../lib/observability'
23
-
import { validateRecord } from '../lexicons/types/place/wisp/fs'
24
-
import { validateRecord as validateSubfsRecord } from '../lexicons/types/place/wisp/subfs'
25
-
import { MAX_SITE_SIZE, MAX_FILE_SIZE, MAX_FILE_COUNT } from '../lib/constants'
24
+
import { createLogger } from '@wisp/observability'
25
+
import { validateRecord, type Directory } from '@wisp/lexicons/types/place/wisp/fs'
26
+
import { validateRecord as validateSubfsRecord } from '@wisp/lexicons/types/place/wisp/subfs'
27
+
import { MAX_SITE_SIZE, MAX_FILE_SIZE, MAX_FILE_COUNT } from '@wisp/constants'
26
28
import {
27
29
createUploadJob,
28
30
getUploadJob,
···
31
33
failUploadJob,
32
34
addJobListener
33
35
} from '../lib/upload-jobs'
36
+
37
+
const logger = createLogger('main-app')
34
38
35
39
function isValidSiteName(siteName: string): boolean {
36
40
if (!siteName || typeof siteName !== 'string') return false;
-40
testDeploy/index.html
-40
testDeploy/index.html
···
1
-
<!DOCTYPE html>
2
-
<html lang="en">
3
-
<head>
4
-
<meta charset="UTF-8">
5
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6
-
<title>Wisp.place Test Site</title>
7
-
<style>
8
-
body {
9
-
font-family: system-ui, -apple-system, sans-serif;
10
-
max-width: 800px;
11
-
margin: 4rem auto;
12
-
padding: 0 2rem;
13
-
line-height: 1.6;
14
-
}
15
-
h1 {
16
-
color: #333;
17
-
}
18
-
.info {
19
-
background: #f0f0f0;
20
-
padding: 1rem;
21
-
border-radius: 8px;
22
-
margin: 2rem 0;
23
-
}
24
-
</style>
25
-
</head>
26
-
<body>
27
-
<h1>Hello from Wisp.place!</h1>
28
-
<p>This is a test deployment using the wisp-cli and Tangled Spindles CI/CD.</p>
29
-
30
-
<div class="info">
31
-
<h2>About this deployment</h2>
32
-
<p>This site was deployed to the AT Protocol using:</p>
33
-
<ul>
34
-
<li>Wisp.place CLI (Rust)</li>
35
-
<li>Tangled Spindles CI/CD</li>
36
-
<li>AT Protocol for decentralized hosting</li>
37
-
</ul>
38
-
</div>
39
-
</body>
40
-
</html>
+1
-1
tsconfig.json
+1
-1
tsconfig.json
···
27
27
/* Modules */
28
28
"module": "ES2022" /* Specify what module code is generated. */,
29
29
// "rootDir": "./", /* Specify the root folder within your source files. */
30
-
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
30
+
"moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */,
31
31
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
32
32
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
33
33
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */