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