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;