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