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 const { gunzipSync } = await import('zlib'); 170 const decompressed = gunzipSync(content); 171 headers['Content-Type'] = meta.mimeType; 172 headers['Cache-Control'] = 'public, max-age=31536000, immutable'; 173 return new Response(decompressed, { headers }); 174 } 175 176 headers['Content-Type'] = meta.mimeType; 177 headers['Content-Encoding'] = 'gzip'; 178 headers['Cache-Control'] = meta.mimeType.startsWith('text/html') 179 ? 'public, max-age=300' 180 : 'public, max-age=31536000, immutable'; 181 return new Response(content, { headers }); 182 } 183 184 // Non-compressed files 185 const mimeType = lookup(cachedFile) || 'application/octet-stream'; 186 headers['Content-Type'] = mimeType; 187 headers['Cache-Control'] = mimeType.startsWith('text/html') 188 ? 'public, max-age=300' 189 : 'public, max-age=31536000, immutable'; 190 return new Response(content, { headers }); 191 } 192 193 // Try index.html for directory-like paths 194 if (!requestPath.includes('.')) { 195 const indexPath = `${requestPath}/index.html`; 196 const indexCacheKey = getCacheKey(did, rkey, indexPath); 197 const indexFile = getCachedFilePath(did, rkey, indexPath); 198 199 let indexContent = fileCache.get(indexCacheKey); 200 let indexMeta = metadataCache.get(indexCacheKey); 201 202 if (!indexContent && await fileExists(indexFile)) { 203 indexContent = await readFile(indexFile); 204 fileCache.set(indexCacheKey, indexContent, indexContent.length); 205 206 const indexMetaFile = `${indexFile}.meta`; 207 if (await fileExists(indexMetaFile)) { 208 const metaJson = await readFile(indexMetaFile, 'utf-8'); 209 indexMeta = JSON.parse(metaJson); 210 metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 211 } 212 } 213 214 if (indexContent) { 215 const headers: Record<string, string> = { 216 'Content-Type': 'text/html; charset=utf-8', 217 'Cache-Control': 'public, max-age=300', 218 }; 219 220 if (indexMeta && indexMeta.encoding === 'gzip') { 221 headers['Content-Encoding'] = 'gzip'; 222 } 223 224 return new Response(indexContent, { headers }); 225 } 226 } 227 228 return new Response('Not Found', { status: 404 }); 229} 230 231// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes 232async function serveFromCacheWithRewrite( 233 did: string, 234 rkey: string, 235 filePath: string, 236 basePath: string, 237 fullUrl?: string, 238 headers?: Record<string, string> 239) { 240 // Check for redirect rules first 241 const redirectCacheKey = `${did}:${rkey}`; 242 let redirectRules = redirectRulesCache.get(redirectCacheKey); 243 244 if (redirectRules === undefined) { 245 // Load rules for the first time 246 redirectRules = await loadRedirectRules(did, rkey); 247 redirectRulesCache.set(redirectCacheKey, redirectRules); 248 } 249 250 // Apply redirect rules if any exist 251 if (redirectRules.length > 0) { 252 const requestPath = '/' + (filePath || ''); 253 const queryParams = fullUrl ? parseQueryString(fullUrl) : {}; 254 const cookies = parseCookies(headers?.['cookie']); 255 256 const redirectMatch = matchRedirectRule(requestPath, redirectRules, { 257 queryParams, 258 headers, 259 cookies, 260 }); 261 262 if (redirectMatch) { 263 const { rule, targetPath, status } = redirectMatch; 264 265 // If not forced, check if the requested file exists before redirecting 266 if (!rule.force) { 267 // Build the expected file path 268 let checkPath = filePath || 'index.html'; 269 if (checkPath.endsWith('/')) { 270 checkPath += 'index.html'; 271 } 272 273 const cachedFile = getCachedFilePath(did, rkey, checkPath); 274 const fileExistsOnDisk = await fileExists(cachedFile); 275 276 // If file exists and redirect is not forced, serve the file normally 277 if (fileExistsOnDisk) { 278 return serveFileInternalWithRewrite(did, rkey, filePath, basePath); 279 } 280 } 281 282 // Handle different status codes 283 if (status === 200) { 284 // Rewrite: serve different content but keep URL the same 285 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 286 return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath); 287 } else if (status === 301 || status === 302) { 288 // External redirect: change the URL 289 // For sites.wisp.place, we need to adjust the target path to include the base path 290 // unless it's an absolute URL 291 let redirectTarget = targetPath; 292 if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) { 293 redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath); 294 } 295 return new Response(null, { 296 status, 297 headers: { 298 'Location': redirectTarget, 299 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0', 300 }, 301 }); 302 } else if (status === 404) { 303 // Custom 404 page 304 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 305 const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath); 306 // Override status to 404 307 return new Response(response.body, { 308 status: 404, 309 headers: response.headers, 310 }); 311 } 312 } 313 } 314 315 // No redirect matched, serve normally 316 return serveFileInternalWithRewrite(did, rkey, filePath, basePath); 317} 318 319// Internal function to serve a file with rewriting 320async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) { 321 // Default to index.html if path is empty or ends with / 322 let requestPath = filePath || 'index.html'; 323 if (requestPath.endsWith('/')) { 324 requestPath += 'index.html'; 325 } 326 327 const cacheKey = getCacheKey(did, rkey, requestPath); 328 const cachedFile = getCachedFilePath(did, rkey, requestPath); 329 330 // Check for rewritten HTML in cache first (if it's HTML) 331 const mimeTypeGuess = lookup(requestPath) || 'application/octet-stream'; 332 if (isHtmlContent(requestPath, mimeTypeGuess)) { 333 const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 334 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 335 if (rewrittenContent) { 336 return new Response(rewrittenContent, { 337 headers: { 338 'Content-Type': 'text/html; charset=utf-8', 339 'Content-Encoding': 'gzip', 340 'Cache-Control': 'public, max-age=300', 341 }, 342 }); 343 } 344 } 345 346 // Check in-memory file cache 347 let content = fileCache.get(cacheKey); 348 let meta = metadataCache.get(cacheKey); 349 350 if (!content && await fileExists(cachedFile)) { 351 // Read from disk and cache 352 content = await readFile(cachedFile); 353 fileCache.set(cacheKey, content, content.length); 354 355 const metaFile = `${cachedFile}.meta`; 356 if (await fileExists(metaFile)) { 357 const metaJson = await readFile(metaFile, 'utf-8'); 358 meta = JSON.parse(metaJson); 359 metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length); 360 } 361 } 362 363 if (content) { 364 const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream'; 365 const isGzipped = meta?.encoding === 'gzip'; 366 367 // Check if this is HTML content that needs rewriting 368 if (isHtmlContent(requestPath, mimeType)) { 369 let htmlContent: string; 370 if (isGzipped) { 371 const { gunzipSync } = await import('zlib'); 372 htmlContent = gunzipSync(content).toString('utf-8'); 373 } else { 374 htmlContent = content.toString('utf-8'); 375 } 376 const rewritten = rewriteHtmlPaths(htmlContent, basePath, requestPath); 377 378 // Recompress and cache the rewritten HTML 379 const { gzipSync } = await import('zlib'); 380 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 381 382 const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 383 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 384 385 return new Response(recompressed, { 386 headers: { 387 'Content-Type': 'text/html; charset=utf-8', 388 'Content-Encoding': 'gzip', 389 'Cache-Control': 'public, max-age=300', 390 }, 391 }); 392 } 393 394 // Non-HTML files: serve as-is 395 const headers: Record<string, string> = { 396 'Content-Type': mimeType, 397 'Cache-Control': 'public, max-age=31536000, immutable', 398 }; 399 400 if (isGzipped) { 401 const shouldServeCompressed = shouldCompressMimeType(mimeType); 402 if (!shouldServeCompressed) { 403 const { gunzipSync } = await import('zlib'); 404 const decompressed = gunzipSync(content); 405 return new Response(decompressed, { headers }); 406 } 407 headers['Content-Encoding'] = 'gzip'; 408 } 409 410 return new Response(content, { headers }); 411 } 412 413 // Try index.html for directory-like paths 414 if (!requestPath.includes('.')) { 415 const indexPath = `${requestPath}/index.html`; 416 const indexCacheKey = getCacheKey(did, rkey, indexPath); 417 const indexFile = getCachedFilePath(did, rkey, indexPath); 418 419 // Check for rewritten index.html in cache 420 const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`); 421 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 422 if (rewrittenContent) { 423 return new Response(rewrittenContent, { 424 headers: { 425 'Content-Type': 'text/html; charset=utf-8', 426 'Content-Encoding': 'gzip', 427 'Cache-Control': 'public, max-age=300', 428 }, 429 }); 430 } 431 432 let indexContent = fileCache.get(indexCacheKey); 433 let indexMeta = metadataCache.get(indexCacheKey); 434 435 if (!indexContent && await fileExists(indexFile)) { 436 indexContent = await readFile(indexFile); 437 fileCache.set(indexCacheKey, indexContent, indexContent.length); 438 439 const indexMetaFile = `${indexFile}.meta`; 440 if (await fileExists(indexMetaFile)) { 441 const metaJson = await readFile(indexMetaFile, 'utf-8'); 442 indexMeta = JSON.parse(metaJson); 443 metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 444 } 445 } 446 447 if (indexContent) { 448 const isGzipped = indexMeta?.encoding === 'gzip'; 449 450 let htmlContent: string; 451 if (isGzipped) { 452 const { gunzipSync } = await import('zlib'); 453 htmlContent = gunzipSync(indexContent).toString('utf-8'); 454 } else { 455 htmlContent = indexContent.toString('utf-8'); 456 } 457 const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath); 458 459 const { gzipSync } = await import('zlib'); 460 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 461 462 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 463 464 return new Response(recompressed, { 465 headers: { 466 'Content-Type': 'text/html; charset=utf-8', 467 'Content-Encoding': 'gzip', 468 'Cache-Control': 'public, max-age=300', 469 }, 470 }); 471 } 472 } 473 474 return new Response('Not Found', { status: 404 }); 475} 476 477// Helper to ensure site is cached 478async function ensureSiteCached(did: string, rkey: string): Promise<boolean> { 479 if (isCached(did, rkey)) { 480 return true; 481 } 482 483 // Fetch and cache the site 484 const siteData = await fetchSiteRecord(did, rkey); 485 if (!siteData) { 486 logger.error('Site record not found', null, { did, rkey }); 487 return false; 488 } 489 490 const pdsEndpoint = await getPdsForDid(did); 491 if (!pdsEndpoint) { 492 logger.error('PDS not found for DID', null, { did }); 493 return false; 494 } 495 496 try { 497 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 498 // Clear redirect rules cache since the site was updated 499 clearRedirectRulesCache(did, rkey); 500 logger.info('Site cached successfully', { did, rkey }); 501 return true; 502 } catch (err) { 503 logger.error('Failed to cache site', err, { did, rkey }); 504 return false; 505 } 506} 507 508const app = new Hono(); 509 510// Add observability middleware 511app.use('*', observabilityMiddleware('hosting-service')); 512 513// Error handler 514app.onError(observabilityErrorHandler('hosting-service')); 515 516// Main site serving route 517app.get('/*', async (c) => { 518 const url = new URL(c.req.url); 519 const hostname = c.req.header('host') || ''; 520 const rawPath = url.pathname.replace(/^\//, ''); 521 const path = sanitizePath(rawPath); 522 523 // Check if this is sites.wisp.place subdomain 524 if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 525 // Sanitize the path FIRST to prevent path traversal 526 const sanitizedFullPath = sanitizePath(rawPath); 527 528 // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 529 const pathParts = sanitizedFullPath.split('/'); 530 if (pathParts.length < 2) { 531 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 532 } 533 534 const identifier = pathParts[0]; 535 const site = pathParts[1]; 536 const filePath = pathParts.slice(2).join('/'); 537 538 // Additional validation: identifier must be a valid DID or handle format 539 if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 540 return c.text('Invalid identifier', 400); 541 } 542 543 // Validate site parameter exists 544 if (!site) { 545 return c.text('Site name required', 400); 546 } 547 548 // Validate site name (rkey) 549 if (!isValidRkey(site)) { 550 return c.text('Invalid site name', 400); 551 } 552 553 // Resolve identifier to DID 554 const did = await resolveDid(identifier); 555 if (!did) { 556 return c.text('Invalid identifier', 400); 557 } 558 559 // Ensure site is cached 560 const cached = await ensureSiteCached(did, site); 561 if (!cached) { 562 return c.text('Site not found', 404); 563 } 564 565 // Serve with HTML path rewriting to handle absolute paths 566 const basePath = `/${identifier}/${site}/`; 567 const headers: Record<string, string> = {}; 568 c.req.raw.headers.forEach((value, key) => { 569 headers[key.toLowerCase()] = value; 570 }); 571 return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers); 572 } 573 574 // Check if this is a DNS hash subdomain 575 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 576 if (dnsMatch) { 577 const hash = dnsMatch[1]; 578 const baseDomain = dnsMatch[2]; 579 580 if (!hash) { 581 return c.text('Invalid DNS hash', 400); 582 } 583 584 if (baseDomain !== BASE_HOST) { 585 return c.text('Invalid base domain', 400); 586 } 587 588 const customDomain = await getCustomDomainByHash(hash); 589 if (!customDomain) { 590 return c.text('Custom domain not found or not verified', 404); 591 } 592 593 if (!customDomain.rkey) { 594 return c.text('Domain not mapped to a site', 404); 595 } 596 597 const rkey = customDomain.rkey; 598 if (!isValidRkey(rkey)) { 599 return c.text('Invalid site configuration', 500); 600 } 601 602 const cached = await ensureSiteCached(customDomain.did, rkey); 603 if (!cached) { 604 return c.text('Site not found', 404); 605 } 606 607 const headers: Record<string, string> = {}; 608 c.req.raw.headers.forEach((value, key) => { 609 headers[key.toLowerCase()] = value; 610 }); 611 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 612 } 613 614 // Route 2: Registered subdomains - /*.wisp.place/* 615 if (hostname.endsWith(`.${BASE_HOST}`)) { 616 const domainInfo = await getWispDomain(hostname); 617 if (!domainInfo) { 618 return c.text('Subdomain not registered', 404); 619 } 620 621 if (!domainInfo.rkey) { 622 return c.text('Domain not mapped to a site', 404); 623 } 624 625 const rkey = domainInfo.rkey; 626 if (!isValidRkey(rkey)) { 627 return c.text('Invalid site configuration', 500); 628 } 629 630 const cached = await ensureSiteCached(domainInfo.did, rkey); 631 if (!cached) { 632 return c.text('Site not found', 404); 633 } 634 635 const headers: Record<string, string> = {}; 636 c.req.raw.headers.forEach((value, key) => { 637 headers[key.toLowerCase()] = value; 638 }); 639 return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers); 640 } 641 642 // Route 1: Custom domains - /* 643 const customDomain = await getCustomDomain(hostname); 644 if (!customDomain) { 645 return c.text('Custom domain not found or not verified', 404); 646 } 647 648 if (!customDomain.rkey) { 649 return c.text('Domain not mapped to a site', 404); 650 } 651 652 const rkey = customDomain.rkey; 653 if (!isValidRkey(rkey)) { 654 return c.text('Invalid site configuration', 500); 655 } 656 657 const cached = await ensureSiteCached(customDomain.did, rkey); 658 if (!cached) { 659 return c.text('Site not found', 404); 660 } 661 662 const headers: Record<string, string> = {}; 663 c.req.raw.headers.forEach((value, key) => { 664 headers[key.toLowerCase()] = value; 665 }); 666 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 667}); 668 669// Internal observability endpoints (for admin panel) 670app.get('/__internal__/observability/logs', (c) => { 671 const query = c.req.query(); 672 const filter: any = {}; 673 if (query.level) filter.level = query.level; 674 if (query.service) filter.service = query.service; 675 if (query.search) filter.search = query.search; 676 if (query.eventType) filter.eventType = query.eventType; 677 if (query.limit) filter.limit = parseInt(query.limit as string); 678 return c.json({ logs: logCollector.getLogs(filter) }); 679}); 680 681app.get('/__internal__/observability/errors', (c) => { 682 const query = c.req.query(); 683 const filter: any = {}; 684 if (query.service) filter.service = query.service; 685 if (query.limit) filter.limit = parseInt(query.limit as string); 686 return c.json({ errors: errorTracker.getErrors(filter) }); 687}); 688 689app.get('/__internal__/observability/metrics', (c) => { 690 const query = c.req.query(); 691 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 692 const stats = metricsCollector.getStats('hosting-service', timeWindow); 693 return c.json({ stats, timeWindow }); 694}); 695 696app.get('/__internal__/observability/cache', async (c) => { 697 const { getCacheStats } = await import('./lib/cache'); 698 const stats = getCacheStats(); 699 return c.json({ cache: stats }); 700}); 701 702export default app;