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;