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

refactor: migrate to maintainable monorepo structure

- Created 7 shared packages (@wisp/*)
- lexicons: 15k+ lines deduplicated
- observability: 670 lines deduplicated
- atproto-utils, fs-utils, database, constants, safe-fetch

Changed files
+3561 -4902
apps
hosting-service
main-app
public
scripts
src
hosting-service
packages
src
testDeploy
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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/.env.example apps/hosting-service/.env.example
hosting-service/.gitignore apps/hosting-service/.gitignore
hosting-service/Dockerfile apps/hosting-service/Dockerfile
hosting-service/README.md apps/hosting-service/README.md
hosting-service/bun.lock apps/hosting-service/bun.lock
-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
-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
··· 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
··· 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
··· 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
··· 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/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/util.ts packages/@wisp/lexicons/src/util.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
+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
··· 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.ts apps/hosting-service/src/lib/html-rewriter.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.ts apps/hosting-service/src/lib/redirects.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/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
··· 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
··· 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
··· 24 24 25 25 /* Code doesn't run in DOM */ 26 26 "lib": ["es2022"], 27 + 28 + /* Workspace Paths */ 29 + "baseUrl": ".", 30 + "paths": { 31 + "@wisp/*": ["../../packages/@wisp/*/src"] 32 + } 27 33 }, 28 34 "include": ["src/**/*"], 29 35 "exclude": ["node_modules", "cache", "dist"]
lexicons/fs.json packages/@wisp/lexicons/lexicons/fs.json
lexicons/settings.json packages/@wisp/lexicons/lexicons/settings.json
lexicons/subfs.json packages/@wisp/lexicons/lexicons/subfs.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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + { 2 + "extends": "../../../tsconfig.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*"], 8 + "exclude": ["node_modules", "dist"] 9 + }
+14
packages/@wisp/constants/package.json
··· 1 + { 2 + "name": "@wisp/constants", 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 + } 14 + }
+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
··· 1 + { 2 + "extends": "../../../tsconfig.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*"], 8 + "exclude": ["node_modules", "dist"] 9 + }
+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
··· 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
··· 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
··· 1 + { 2 + "extends": "../../../tsconfig.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*"], 8 + "exclude": ["node_modules", "dist"] 9 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + { 2 + "extends": "../../../tsconfig.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*"], 8 + "exclude": ["node_modules", "dist"] 9 + }
+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
··· 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
··· 1 + { 2 + "extends": "../../../tsconfig.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src", 6 + "declaration": true, 7 + "declarationMap": true 8 + }, 9 + "include": ["src/**/*"], 10 + "exclude": ["node_modules", "dist"] 11 + }
+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
··· 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
··· 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
··· 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
··· 1 + { 2 + "extends": "../../../tsconfig.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*"], 8 + "exclude": ["node_modules", "dist"] 9 + }
+14
packages/@wisp/safe-fetch/package.json
··· 1 + { 2 + "name": "@wisp/safe-fetch", 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 + } 14 + }
+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
··· 1 + { 2 + "extends": "../../../tsconfig.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*"], 8 + "exclude": ["node_modules", "dist"] 9 + }
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/admin/admin.tsx apps/main-app/public/admin/admin.tsx
public/admin/index.html apps/main-app/public/admin/index.html
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-512x512.png apps/main-app/public/android-chrome-512x512.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/button.tsx apps/main-app/public/components/ui/button.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/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/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/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/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/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/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/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/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/UploadTab.tsx apps/main-app/public/editor/tabs/UploadTab.tsx
public/favicon-16x16.png apps/main-app/public/favicon-16x16.png
public/favicon-32x32.png apps/main-app/public/favicon-32x32.png
public/favicon.ico apps/main-app/public/favicon.ico
public/index.html apps/main-app/public/index.html
public/index.tsx apps/main-app/public/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/utils.ts apps/main-app/public/lib/utils.ts
public/onboarding/index.html apps/main-app/public/onboarding/index.html
public/onboarding/onboarding.tsx apps/main-app/public/onboarding/onboarding.tsx
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/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/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/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/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/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/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_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/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/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_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_wlo_moe.png apps/main-app/public/screenshots/www_wlo_moe.png
public/site.webmanifest apps/main-app/public/site.webmanifest
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
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 // Quick script to create admin user with randomly generated password 2 - import { adminAuth } from './src/lib/admin-auth' 2 + import { adminAuth } from '../src/lib/admin-auth' 3 3 import { randomBytes } from 'crypto' 4 4 5 5 // Generate a secure random password
scripts/screenshot-sites.ts apps/main-app/scripts/screenshot-sites.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
··· 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
··· 7 7 ValidationError, 8 8 type ValidationResult, 9 9 } from '@atproto/lexicon' 10 - import { type $Typed, is$typed, maybe$typed } from './util.js' 10 + import { type $Typed, is$typed, maybe$typed } from './util' 11 11 12 12 export const schemaDict = { 13 13 PlaceWispFs: {
-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
··· 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
··· 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
··· 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
-4
src/lib/constants.ts
··· 1 - export const BASE_HOST = Bun.env.BASE_DOMAIN || "wisp.place"; 2 - export const MAX_SITE_SIZE = 300 * 1024 * 1024; //300MB 3 - export const MAX_FILE_SIZE = 100 * 1024 * 1024; //100MB 4 - export const MAX_FILE_COUNT = 1000;
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/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 import { SQL } from "bun"; 2 - import { BASE_HOST } from "./constants"; 2 + import { BASE_HOST } from "@wisp/constants"; 3 3 4 4 export const db = new SQL( 5 5 process.env.NODE_ENV === 'production'
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
-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
··· 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
··· 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
··· 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/types.ts apps/main-app/src/lib/types.ts
+3 -1
src/lib/upload-jobs.ts apps/main-app/src/lib/upload-jobs.ts
··· 1 - import { logger } from './observability'; 1 + import { createLogger } from '@wisp/observability'; 2 + 3 + const logger = createLogger('main-app'); 2 4 3 5 export type UploadJobStatus = 'pending' | 'processing' | 'uploading' | 'completed' | 'failed'; 4 6
src/lib/wisp-auth.ts apps/main-app/src/lib/wisp-auth.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
··· 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 // 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 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
··· 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
··· 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 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
··· 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
··· 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
··· 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. */