Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import { Hono } from 'hono'; 2import { cors } from 'hono/cors'; 3import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 4import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils'; 5import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 6import { existsSync } from 'fs'; 7import { readFile, access } from 'fs/promises'; 8import { lookup } from 'mime-types'; 9import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability'; 10import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, type FileMetadata } from './lib/cache'; 11import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString, type RedirectRule } from './lib/redirects'; 12 13const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 14 15/** 16 * Validate site name (rkey) to prevent injection attacks 17 * Must match AT Protocol rkey format 18 */ 19function isValidRkey(rkey: string): boolean { 20 if (!rkey || typeof rkey !== 'string') return false; 21 if (rkey.length < 1 || rkey.length > 512) return false; 22 if (rkey === '.' || rkey === '..') return false; 23 if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false; 24 const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/; 25 return validRkeyPattern.test(rkey); 26} 27 28/** 29 * Async file existence check 30 */ 31async function fileExists(path: string): Promise<boolean> { 32 try { 33 await access(path); 34 return true; 35 } catch { 36 return false; 37 } 38} 39 40// Cache for redirect rules (per site) 41const redirectRulesCache = new Map<string, RedirectRule[]>(); 42 43/** 44 * Clear redirect rules cache for a specific site 45 * Should be called when a site is updated/recached 46 */ 47export function clearRedirectRulesCache(did: string, rkey: string) { 48 const cacheKey = `${did}:${rkey}`; 49 redirectRulesCache.delete(cacheKey); 50} 51 52// Helper to serve files from cache 53async function serveFromCache( 54 did: string, 55 rkey: string, 56 filePath: string, 57 fullUrl?: string, 58 headers?: Record<string, string> 59) { 60 // Check for redirect rules first 61 const redirectCacheKey = `${did}:${rkey}`; 62 let redirectRules = redirectRulesCache.get(redirectCacheKey); 63 64 if (redirectRules === undefined) { 65 // Load rules for the first time 66 redirectRules = await loadRedirectRules(did, rkey); 67 redirectRulesCache.set(redirectCacheKey, redirectRules); 68 } 69 70 // Apply redirect rules if any exist 71 if (redirectRules.length > 0) { 72 const requestPath = '/' + (filePath || ''); 73 const queryParams = fullUrl ? parseQueryString(fullUrl) : {}; 74 const cookies = parseCookies(headers?.['cookie']); 75 76 const redirectMatch = matchRedirectRule(requestPath, redirectRules, { 77 queryParams, 78 headers, 79 cookies, 80 }); 81 82 if (redirectMatch) { 83 const { rule, targetPath, status } = redirectMatch; 84 85 // If not forced, check if the requested file exists before redirecting 86 if (!rule.force) { 87 // Build the expected file path 88 let checkPath = filePath || 'index.html'; 89 if (checkPath.endsWith('/')) { 90 checkPath += 'index.html'; 91 } 92 93 const cachedFile = getCachedFilePath(did, rkey, checkPath); 94 const fileExistsOnDisk = await fileExists(cachedFile); 95 96 // If file exists and redirect is not forced, serve the file normally 97 if (fileExistsOnDisk) { 98 return serveFileInternal(did, rkey, filePath); 99 } 100 } 101 102 // Handle different status codes 103 if (status === 200) { 104 // Rewrite: serve different content but keep URL the same 105 // Remove leading slash for internal path resolution 106 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 107 return serveFileInternal(did, rkey, rewritePath); 108 } else if (status === 301 || status === 302) { 109 // External redirect: change the URL 110 return new Response(null, { 111 status, 112 headers: { 113 'Location': targetPath, 114 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0', 115 }, 116 }); 117 } else if (status === 404) { 118 // Custom 404 page 119 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 120 const response = await serveFileInternal(did, rkey, custom404Path); 121 // Override status to 404 122 return new Response(response.body, { 123 status: 404, 124 headers: response.headers, 125 }); 126 } 127 } 128 } 129 130 // No redirect matched, serve normally 131 return serveFileInternal(did, rkey, filePath); 132} 133 134// Internal function to serve a file (used by both normal serving and rewrites) 135async function serveFileInternal(did: string, rkey: string, filePath: string) { 136 // Default to index.html if path is empty or ends with / 137 let requestPath = filePath || 'index.html'; 138 if (requestPath.endsWith('/')) { 139 requestPath += 'index.html'; 140 } 141 142 const cacheKey = getCacheKey(did, rkey, requestPath); 143 const cachedFile = getCachedFilePath(did, rkey, requestPath); 144 145 // Check in-memory cache first 146 let content = fileCache.get(cacheKey); 147 let meta = metadataCache.get(cacheKey); 148 149 if (!content && await fileExists(cachedFile)) { 150 // Read from disk and cache 151 content = await readFile(cachedFile); 152 fileCache.set(cacheKey, content, content.length); 153 154 const metaFile = `${cachedFile}.meta`; 155 if (await fileExists(metaFile)) { 156 const metaJson = await readFile(metaFile, 'utf-8'); 157 meta = JSON.parse(metaJson); 158 metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length); 159 } 160 } 161 162 if (content) { 163 // Build headers with caching 164 const headers: Record<string, string> = {}; 165 166 if (meta && meta.encoding === 'gzip' && meta.mimeType) { 167 const shouldServeCompressed = shouldCompressMimeType(meta.mimeType); 168 169 if (!shouldServeCompressed) { 170 // Verify content is actually gzipped before attempting decompression 171 const isGzipped = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b; 172 if (isGzipped) { 173 const { gunzipSync } = await import('zlib'); 174 const decompressed = gunzipSync(content); 175 headers['Content-Type'] = meta.mimeType; 176 headers['Cache-Control'] = 'public, max-age=31536000, immutable'; 177 return new Response(decompressed, { headers }); 178 } else { 179 // Meta says gzipped but content isn't - serve as-is 180 console.warn(`File ${filePath} has gzip encoding in meta but content lacks gzip magic bytes`); 181 headers['Content-Type'] = meta.mimeType; 182 headers['Cache-Control'] = 'public, max-age=31536000, immutable'; 183 return new Response(content, { headers }); 184 } 185 } 186 187 headers['Content-Type'] = meta.mimeType; 188 headers['Content-Encoding'] = 'gzip'; 189 headers['Cache-Control'] = meta.mimeType.startsWith('text/html') 190 ? 'public, max-age=300' 191 : 'public, max-age=31536000, immutable'; 192 return new Response(content, { headers }); 193 } 194 195 // Non-compressed files 196 const mimeType = lookup(cachedFile) || 'application/octet-stream'; 197 headers['Content-Type'] = mimeType; 198 headers['Cache-Control'] = mimeType.startsWith('text/html') 199 ? 'public, max-age=300' 200 : 'public, max-age=31536000, immutable'; 201 return new Response(content, { headers }); 202 } 203 204 // Try index.html for directory-like paths 205 if (!requestPath.includes('.')) { 206 const indexPath = `${requestPath}/index.html`; 207 const indexCacheKey = getCacheKey(did, rkey, indexPath); 208 const indexFile = getCachedFilePath(did, rkey, indexPath); 209 210 let indexContent = fileCache.get(indexCacheKey); 211 let indexMeta = metadataCache.get(indexCacheKey); 212 213 if (!indexContent && await fileExists(indexFile)) { 214 indexContent = await readFile(indexFile); 215 fileCache.set(indexCacheKey, indexContent, indexContent.length); 216 217 const indexMetaFile = `${indexFile}.meta`; 218 if (await fileExists(indexMetaFile)) { 219 const metaJson = await readFile(indexMetaFile, 'utf-8'); 220 indexMeta = JSON.parse(metaJson); 221 metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 222 } 223 } 224 225 if (indexContent) { 226 const headers: Record<string, string> = { 227 'Content-Type': 'text/html; charset=utf-8', 228 'Cache-Control': 'public, max-age=300', 229 }; 230 231 if (indexMeta && indexMeta.encoding === 'gzip') { 232 headers['Content-Encoding'] = 'gzip'; 233 } 234 235 return new Response(indexContent, { headers }); 236 } 237 } 238 239 return new Response('Not Found', { status: 404 }); 240} 241 242// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes 243async function serveFromCacheWithRewrite( 244 did: string, 245 rkey: string, 246 filePath: string, 247 basePath: string, 248 fullUrl?: string, 249 headers?: Record<string, string> 250) { 251 // Check for redirect rules first 252 const redirectCacheKey = `${did}:${rkey}`; 253 let redirectRules = redirectRulesCache.get(redirectCacheKey); 254 255 if (redirectRules === undefined) { 256 // Load rules for the first time 257 redirectRules = await loadRedirectRules(did, rkey); 258 redirectRulesCache.set(redirectCacheKey, redirectRules); 259 } 260 261 // Apply redirect rules if any exist 262 if (redirectRules.length > 0) { 263 const requestPath = '/' + (filePath || ''); 264 const queryParams = fullUrl ? parseQueryString(fullUrl) : {}; 265 const cookies = parseCookies(headers?.['cookie']); 266 267 const redirectMatch = matchRedirectRule(requestPath, redirectRules, { 268 queryParams, 269 headers, 270 cookies, 271 }); 272 273 if (redirectMatch) { 274 const { rule, targetPath, status } = redirectMatch; 275 276 // If not forced, check if the requested file exists before redirecting 277 if (!rule.force) { 278 // Build the expected file path 279 let checkPath = filePath || 'index.html'; 280 if (checkPath.endsWith('/')) { 281 checkPath += 'index.html'; 282 } 283 284 const cachedFile = getCachedFilePath(did, rkey, checkPath); 285 const fileExistsOnDisk = await fileExists(cachedFile); 286 287 // If file exists and redirect is not forced, serve the file normally 288 if (fileExistsOnDisk) { 289 return serveFileInternalWithRewrite(did, rkey, filePath, basePath); 290 } 291 } 292 293 // Handle different status codes 294 if (status === 200) { 295 // Rewrite: serve different content but keep URL the same 296 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 297 return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath); 298 } else if (status === 301 || status === 302) { 299 // External redirect: change the URL 300 // For sites.wisp.place, we need to adjust the target path to include the base path 301 // unless it's an absolute URL 302 let redirectTarget = targetPath; 303 if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) { 304 redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath); 305 } 306 return new Response(null, { 307 status, 308 headers: { 309 'Location': redirectTarget, 310 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0', 311 }, 312 }); 313 } else if (status === 404) { 314 // Custom 404 page 315 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 316 const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath); 317 // Override status to 404 318 return new Response(response.body, { 319 status: 404, 320 headers: response.headers, 321 }); 322 } 323 } 324 } 325 326 // No redirect matched, serve normally 327 return serveFileInternalWithRewrite(did, rkey, filePath, basePath); 328} 329 330// Internal function to serve a file with rewriting 331async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) { 332 // Default to index.html if path is empty or ends with / 333 let requestPath = filePath || 'index.html'; 334 if (requestPath.endsWith('/')) { 335 requestPath += 'index.html'; 336 } 337 338 const cacheKey = getCacheKey(did, rkey, requestPath); 339 const cachedFile = getCachedFilePath(did, rkey, requestPath); 340 341 // Check for rewritten HTML in cache first (if it's HTML) 342 const mimeTypeGuess = lookup(requestPath) || 'application/octet-stream'; 343 if (isHtmlContent(requestPath, mimeTypeGuess)) { 344 const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 345 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 346 if (rewrittenContent) { 347 return new Response(rewrittenContent, { 348 headers: { 349 'Content-Type': 'text/html; charset=utf-8', 350 'Content-Encoding': 'gzip', 351 'Cache-Control': 'public, max-age=300', 352 }, 353 }); 354 } 355 } 356 357 // Check in-memory file cache 358 let content = fileCache.get(cacheKey); 359 let meta = metadataCache.get(cacheKey); 360 361 if (!content && await fileExists(cachedFile)) { 362 // Read from disk and cache 363 content = await readFile(cachedFile); 364 fileCache.set(cacheKey, content, content.length); 365 366 const metaFile = `${cachedFile}.meta`; 367 if (await fileExists(metaFile)) { 368 const metaJson = await readFile(metaFile, 'utf-8'); 369 meta = JSON.parse(metaJson); 370 metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length); 371 } 372 } 373 374 if (content) { 375 const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream'; 376 const isGzipped = meta?.encoding === 'gzip'; 377 378 // Check if this is HTML content that needs rewriting 379 if (isHtmlContent(requestPath, mimeType)) { 380 let htmlContent: string; 381 if (isGzipped) { 382 // Verify content is actually gzipped 383 const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b; 384 if (hasGzipMagic) { 385 const { gunzipSync } = await import('zlib'); 386 htmlContent = gunzipSync(content).toString('utf-8'); 387 } else { 388 console.warn(`File ${requestPath} marked as gzipped but lacks magic bytes, serving as-is`); 389 htmlContent = content.toString('utf-8'); 390 } 391 } else { 392 htmlContent = content.toString('utf-8'); 393 } 394 const rewritten = rewriteHtmlPaths(htmlContent, basePath, requestPath); 395 396 // Recompress and cache the rewritten HTML 397 const { gzipSync } = await import('zlib'); 398 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 399 400 const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 401 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 402 403 return new Response(recompressed, { 404 headers: { 405 'Content-Type': 'text/html; charset=utf-8', 406 'Content-Encoding': 'gzip', 407 'Cache-Control': 'public, max-age=300', 408 }, 409 }); 410 } 411 412 // Non-HTML files: serve as-is 413 const headers: Record<string, string> = { 414 'Content-Type': mimeType, 415 'Cache-Control': 'public, max-age=31536000, immutable', 416 }; 417 418 if (isGzipped) { 419 const shouldServeCompressed = shouldCompressMimeType(mimeType); 420 if (!shouldServeCompressed) { 421 // Verify content is actually gzipped 422 const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b; 423 if (hasGzipMagic) { 424 const { gunzipSync } = await import('zlib'); 425 const decompressed = gunzipSync(content); 426 return new Response(decompressed, { headers }); 427 } else { 428 console.warn(`File ${requestPath} marked as gzipped but lacks magic bytes, serving as-is`); 429 return new Response(content, { headers }); 430 } 431 } 432 headers['Content-Encoding'] = 'gzip'; 433 } 434 435 return new Response(content, { headers }); 436 } 437 438 // Try index.html for directory-like paths 439 if (!requestPath.includes('.')) { 440 const indexPath = `${requestPath}/index.html`; 441 const indexCacheKey = getCacheKey(did, rkey, indexPath); 442 const indexFile = getCachedFilePath(did, rkey, indexPath); 443 444 // Check for rewritten index.html in cache 445 const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`); 446 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 447 if (rewrittenContent) { 448 return new Response(rewrittenContent, { 449 headers: { 450 'Content-Type': 'text/html; charset=utf-8', 451 'Content-Encoding': 'gzip', 452 'Cache-Control': 'public, max-age=300', 453 }, 454 }); 455 } 456 457 let indexContent = fileCache.get(indexCacheKey); 458 let indexMeta = metadataCache.get(indexCacheKey); 459 460 if (!indexContent && await fileExists(indexFile)) { 461 indexContent = await readFile(indexFile); 462 fileCache.set(indexCacheKey, indexContent, indexContent.length); 463 464 const indexMetaFile = `${indexFile}.meta`; 465 if (await fileExists(indexMetaFile)) { 466 const metaJson = await readFile(indexMetaFile, 'utf-8'); 467 indexMeta = JSON.parse(metaJson); 468 metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 469 } 470 } 471 472 if (indexContent) { 473 const isGzipped = indexMeta?.encoding === 'gzip'; 474 475 let htmlContent: string; 476 if (isGzipped) { 477 // Verify content is actually gzipped 478 const hasGzipMagic = indexContent.length >= 2 && indexContent[0] === 0x1f && indexContent[1] === 0x8b; 479 if (hasGzipMagic) { 480 const { gunzipSync } = await import('zlib'); 481 htmlContent = gunzipSync(indexContent).toString('utf-8'); 482 } else { 483 console.warn(`Index file marked as gzipped but lacks magic bytes, serving as-is`); 484 htmlContent = indexContent.toString('utf-8'); 485 } 486 } else { 487 htmlContent = indexContent.toString('utf-8'); 488 } 489 const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath); 490 491 const { gzipSync } = await import('zlib'); 492 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 493 494 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 495 496 return new Response(recompressed, { 497 headers: { 498 'Content-Type': 'text/html; charset=utf-8', 499 'Content-Encoding': 'gzip', 500 'Cache-Control': 'public, max-age=300', 501 }, 502 }); 503 } 504 } 505 506 return new Response('Not Found', { status: 404 }); 507} 508 509// Helper to ensure site is cached 510async function ensureSiteCached(did: string, rkey: string): Promise<boolean> { 511 if (isCached(did, rkey)) { 512 return true; 513 } 514 515 // Fetch and cache the site 516 const siteData = await fetchSiteRecord(did, rkey); 517 if (!siteData) { 518 logger.error('Site record not found', null, { did, rkey }); 519 return false; 520 } 521 522 const pdsEndpoint = await getPdsForDid(did); 523 if (!pdsEndpoint) { 524 logger.error('PDS not found for DID', null, { did }); 525 return false; 526 } 527 528 try { 529 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 530 // Clear redirect rules cache since the site was updated 531 clearRedirectRulesCache(did, rkey); 532 logger.info('Site cached successfully', { did, rkey }); 533 return true; 534 } catch (err) { 535 logger.error('Failed to cache site', err, { did, rkey }); 536 return false; 537 } 538} 539 540const app = new Hono(); 541 542// Add CORS middleware - allow all origins for static site hosting 543app.use('*', cors({ 544 origin: '*', 545 allowMethods: ['GET', 'HEAD', 'OPTIONS'], 546 allowHeaders: ['Content-Type', 'Authorization'], 547 exposeHeaders: ['Content-Length', 'Content-Type', 'Content-Encoding', 'Cache-Control'], 548 maxAge: 86400, // 24 hours 549 credentials: false, 550})); 551 552// Add observability middleware 553app.use('*', observabilityMiddleware('hosting-service')); 554 555// Error handler 556app.onError(observabilityErrorHandler('hosting-service')); 557 558// Main site serving route 559app.get('/*', async (c) => { 560 const url = new URL(c.req.url); 561 const hostname = c.req.header('host') || ''; 562 const rawPath = url.pathname.replace(/^\//, ''); 563 const path = sanitizePath(rawPath); 564 565 // Check if this is sites.wisp.place subdomain 566 if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 567 // Sanitize the path FIRST to prevent path traversal 568 const sanitizedFullPath = sanitizePath(rawPath); 569 570 // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 571 const pathParts = sanitizedFullPath.split('/'); 572 if (pathParts.length < 2) { 573 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 574 } 575 576 const identifier = pathParts[0]; 577 const site = pathParts[1]; 578 const filePath = pathParts.slice(2).join('/'); 579 580 // Additional validation: identifier must be a valid DID or handle format 581 if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 582 return c.text('Invalid identifier', 400); 583 } 584 585 // Validate site parameter exists 586 if (!site) { 587 return c.text('Site name required', 400); 588 } 589 590 // Validate site name (rkey) 591 if (!isValidRkey(site)) { 592 return c.text('Invalid site name', 400); 593 } 594 595 // Resolve identifier to DID 596 const did = await resolveDid(identifier); 597 if (!did) { 598 return c.text('Invalid identifier', 400); 599 } 600 601 // Ensure site is cached 602 const cached = await ensureSiteCached(did, site); 603 if (!cached) { 604 return c.text('Site not found', 404); 605 } 606 607 // Serve with HTML path rewriting to handle absolute paths 608 const basePath = `/${identifier}/${site}/`; 609 const headers: Record<string, string> = {}; 610 c.req.raw.headers.forEach((value, key) => { 611 headers[key.toLowerCase()] = value; 612 }); 613 return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers); 614 } 615 616 // Check if this is a DNS hash subdomain 617 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 618 if (dnsMatch) { 619 const hash = dnsMatch[1]; 620 const baseDomain = dnsMatch[2]; 621 622 if (!hash) { 623 return c.text('Invalid DNS hash', 400); 624 } 625 626 if (baseDomain !== BASE_HOST) { 627 return c.text('Invalid base domain', 400); 628 } 629 630 const customDomain = await getCustomDomainByHash(hash); 631 if (!customDomain) { 632 return c.text('Custom domain not found or not verified', 404); 633 } 634 635 if (!customDomain.rkey) { 636 return c.text('Domain not mapped to a site', 404); 637 } 638 639 const rkey = customDomain.rkey; 640 if (!isValidRkey(rkey)) { 641 return c.text('Invalid site configuration', 500); 642 } 643 644 const cached = await ensureSiteCached(customDomain.did, rkey); 645 if (!cached) { 646 return c.text('Site not found', 404); 647 } 648 649 const headers: Record<string, string> = {}; 650 c.req.raw.headers.forEach((value, key) => { 651 headers[key.toLowerCase()] = value; 652 }); 653 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 654 } 655 656 // Route 2: Registered subdomains - /*.wisp.place/* 657 if (hostname.endsWith(`.${BASE_HOST}`)) { 658 const domainInfo = await getWispDomain(hostname); 659 if (!domainInfo) { 660 return c.text('Subdomain not registered', 404); 661 } 662 663 if (!domainInfo.rkey) { 664 return c.text('Domain not mapped to a site', 404); 665 } 666 667 const rkey = domainInfo.rkey; 668 if (!isValidRkey(rkey)) { 669 return c.text('Invalid site configuration', 500); 670 } 671 672 const cached = await ensureSiteCached(domainInfo.did, rkey); 673 if (!cached) { 674 return c.text('Site not found', 404); 675 } 676 677 const headers: Record<string, string> = {}; 678 c.req.raw.headers.forEach((value, key) => { 679 headers[key.toLowerCase()] = value; 680 }); 681 return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers); 682 } 683 684 // Route 1: Custom domains - /* 685 const customDomain = await getCustomDomain(hostname); 686 if (!customDomain) { 687 return c.text('Custom domain not found or not verified', 404); 688 } 689 690 if (!customDomain.rkey) { 691 return c.text('Domain not mapped to a site', 404); 692 } 693 694 const rkey = customDomain.rkey; 695 if (!isValidRkey(rkey)) { 696 return c.text('Invalid site configuration', 500); 697 } 698 699 const cached = await ensureSiteCached(customDomain.did, rkey); 700 if (!cached) { 701 return c.text('Site not found', 404); 702 } 703 704 const headers: Record<string, string> = {}; 705 c.req.raw.headers.forEach((value, key) => { 706 headers[key.toLowerCase()] = value; 707 }); 708 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 709}); 710 711// Internal observability endpoints (for admin panel) 712app.get('/__internal__/observability/logs', (c) => { 713 const query = c.req.query(); 714 const filter: any = {}; 715 if (query.level) filter.level = query.level; 716 if (query.service) filter.service = query.service; 717 if (query.search) filter.search = query.search; 718 if (query.eventType) filter.eventType = query.eventType; 719 if (query.limit) filter.limit = parseInt(query.limit as string); 720 return c.json({ logs: logCollector.getLogs(filter) }); 721}); 722 723app.get('/__internal__/observability/errors', (c) => { 724 const query = c.req.query(); 725 const filter: any = {}; 726 if (query.service) filter.service = query.service; 727 if (query.limit) filter.limit = parseInt(query.limit as string); 728 return c.json({ errors: errorTracker.getErrors(filter) }); 729}); 730 731app.get('/__internal__/observability/metrics', (c) => { 732 const query = c.req.query(); 733 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 734 const stats = metricsCollector.getStats('hosting-service', timeWindow); 735 return c.json({ stats, timeWindow }); 736}); 737 738app.get('/__internal__/observability/cache', async (c) => { 739 const { getCacheStats } = await import('./lib/cache'); 740 const stats = getCacheStats(); 741 return c.json({ cache: stats }); 742}); 743 744export default app;