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