Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1/** 2 * Main server entry point for the hosting service 3 * Handles routing and request dispatching 4 */ 5 6import { Hono } from 'hono'; 7import { cors } from 'hono/cors'; 8import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 9import { resolveDid } from './lib/utils'; 10import { logCollector, errorTracker, metricsCollector } from '@wisp/observability'; 11import { observabilityMiddleware, observabilityErrorHandler } from '@wisp/observability/middleware/hono'; 12import { sanitizePath } from '@wisp/fs-utils'; 13import { isSiteBeingCached } from './lib/cache'; 14import { isValidRkey, extractHeaders } from './lib/request-utils'; 15import { siteUpdatingResponse } from './lib/page-generators'; 16import { ensureSiteCached } from './lib/site-cache'; 17import { serveFromCache, serveFromCacheWithRewrite } from './lib/file-serving'; 18 19const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 20 21const app = new Hono(); 22 23// Add CORS middleware - allow all origins for static site hosting 24app.use('*', cors({ 25 origin: '*', 26 allowMethods: ['GET', 'HEAD', 'OPTIONS'], 27 allowHeaders: ['Content-Type', 'Authorization'], 28 exposeHeaders: ['Content-Length', 'Content-Type', 'Content-Encoding', 'Cache-Control'], 29 maxAge: 86400, // 24 hours 30 credentials: false, 31})); 32 33// Add observability middleware 34app.use('*', observabilityMiddleware('hosting-service')); 35 36// Error handler 37app.onError(observabilityErrorHandler('hosting-service')); 38 39// Main site serving route 40app.get('/*', async (c) => { 41 const url = new URL(c.req.url); 42 const hostname = c.req.header('host') || ''; 43 const rawPath = url.pathname.replace(/^\//, ''); 44 const path = sanitizePath(rawPath); 45 46 // Check if this is sites.wisp.place subdomain (strip port for comparison) 47 const hostnameWithoutPort = hostname.split(':')[0]; 48 if (hostnameWithoutPort === `sites.${BASE_HOST}`) { 49 // Sanitize the path FIRST to prevent path traversal 50 const sanitizedFullPath = sanitizePath(rawPath); 51 52 // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 53 const pathParts = sanitizedFullPath.split('/'); 54 if (pathParts.length < 2) { 55 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 56 } 57 58 const identifier = pathParts[0]; 59 const site = pathParts[1]; 60 const filePath = pathParts.slice(2).join('/'); 61 62 // Additional validation: identifier must be a valid DID or handle format 63 if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 64 return c.text('Invalid identifier', 400); 65 } 66 67 // Validate site parameter exists 68 if (!site) { 69 return c.text('Site name required', 400); 70 } 71 72 // Validate site name (rkey) 73 if (!isValidRkey(site)) { 74 return c.text('Invalid site name', 400); 75 } 76 77 // Resolve identifier to DID 78 const did = await resolveDid(identifier); 79 if (!did) { 80 return c.text('Invalid identifier', 400); 81 } 82 83 // Check if site is currently being cached - return updating response early 84 if (isSiteBeingCached(did, site)) { 85 return siteUpdatingResponse(); 86 } 87 88 // Ensure site is cached 89 const cached = await ensureSiteCached(did, site); 90 if (!cached) { 91 return c.text('Site not found', 404); 92 } 93 94 // Serve with HTML path rewriting to handle absolute paths 95 const basePath = `/${identifier}/${site}/`; 96 const headers = extractHeaders(c.req.raw.headers); 97 return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers); 98 } 99 100 // Check if this is a DNS hash subdomain 101 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 102 if (dnsMatch) { 103 const hash = dnsMatch[1]; 104 const baseDomain = dnsMatch[2]; 105 106 if (!hash) { 107 return c.text('Invalid DNS hash', 400); 108 } 109 110 if (baseDomain !== BASE_HOST) { 111 return c.text('Invalid base domain', 400); 112 } 113 114 const customDomain = await getCustomDomainByHash(hash); 115 if (!customDomain) { 116 return c.text('Custom domain not found or not verified', 404); 117 } 118 119 if (!customDomain.rkey) { 120 return c.text('Domain not mapped to a site', 404); 121 } 122 123 const rkey = customDomain.rkey; 124 if (!isValidRkey(rkey)) { 125 return c.text('Invalid site configuration', 500); 126 } 127 128 // Check if site is currently being cached - return updating response early 129 if (isSiteBeingCached(customDomain.did, rkey)) { 130 return siteUpdatingResponse(); 131 } 132 133 const cached = await ensureSiteCached(customDomain.did, rkey); 134 if (!cached) { 135 return c.text('Site not found', 404); 136 } 137 138 const headers = extractHeaders(c.req.raw.headers); 139 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 140 } 141 142 // Route 2: Registered subdomains - /*.wisp.place/* 143 if (hostname.endsWith(`.${BASE_HOST}`)) { 144 const domainInfo = await getWispDomain(hostname); 145 if (!domainInfo) { 146 return c.text('Subdomain not registered', 404); 147 } 148 149 if (!domainInfo.rkey) { 150 return c.text('Domain not mapped to a site', 404); 151 } 152 153 const rkey = domainInfo.rkey; 154 if (!isValidRkey(rkey)) { 155 return c.text('Invalid site configuration', 500); 156 } 157 158 // Check if site is currently being cached - return updating response early 159 if (isSiteBeingCached(domainInfo.did, rkey)) { 160 return siteUpdatingResponse(); 161 } 162 163 const cached = await ensureSiteCached(domainInfo.did, rkey); 164 if (!cached) { 165 return c.text('Site not found', 404); 166 } 167 168 const headers = extractHeaders(c.req.raw.headers); 169 return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers); 170 } 171 172 // Route 1: Custom domains - /* 173 const customDomain = await getCustomDomain(hostname); 174 if (!customDomain) { 175 return c.text('Custom domain not found or not verified', 404); 176 } 177 178 if (!customDomain.rkey) { 179 return c.text('Domain not mapped to a site', 404); 180 } 181 182 const rkey = customDomain.rkey; 183 if (!isValidRkey(rkey)) { 184 return c.text('Invalid site configuration', 500); 185 } 186 187 // Check if site is currently being cached - return updating response early 188 if (isSiteBeingCached(customDomain.did, rkey)) { 189 return siteUpdatingResponse(); 190 } 191 192 const cached = await ensureSiteCached(customDomain.did, rkey); 193 if (!cached) { 194 return c.text('Site not found', 404); 195 } 196 197 const headers = extractHeaders(c.req.raw.headers); 198 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 199}); 200 201// Internal observability endpoints (for admin panel) 202app.get('/__internal__/observability/logs', (c) => { 203 const query = c.req.query(); 204 const filter: any = {}; 205 if (query.level) filter.level = query.level; 206 if (query.service) filter.service = query.service; 207 if (query.search) filter.search = query.search; 208 if (query.eventType) filter.eventType = query.eventType; 209 if (query.limit) filter.limit = parseInt(query.limit as string); 210 return c.json({ logs: logCollector.getLogs(filter) }); 211}); 212 213app.get('/__internal__/observability/errors', (c) => { 214 const query = c.req.query(); 215 const filter: any = {}; 216 if (query.service) filter.service = query.service; 217 if (query.limit) filter.limit = parseInt(query.limit as string); 218 return c.json({ errors: errorTracker.getErrors(filter) }); 219}); 220 221app.get('/__internal__/observability/metrics', (c) => { 222 const query = c.req.query(); 223 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 224 const stats = metricsCollector.getStats('hosting-service', timeWindow); 225 return c.json({ stats, timeWindow }); 226}); 227 228app.get('/__internal__/observability/cache', async (c) => { 229 const { getCacheStats } = await import('./lib/cache'); 230 const stats = getCacheStats(); 231 return c.json({ cache: stats }); 232}); 233 234export default app;