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;