forked from
nekomimi.pet/wisp.place-monorepo
Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
1import { Hono } from 'hono';
2import { serveStatic } from 'hono/bun';
3import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
4import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached } from './lib/utils';
5import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
6import { existsSync } from 'fs';
7
8const app = new Hono();
9
10const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
11
12// Helper to serve files from cache
13async function serveFromCache(did: string, rkey: string, filePath: string) {
14 // Default to index.html if path is empty or ends with /
15 let requestPath = filePath || 'index.html';
16 if (requestPath.endsWith('/')) {
17 requestPath += 'index.html';
18 }
19
20 const cachedFile = getCachedFilePath(did, rkey, requestPath);
21
22 if (existsSync(cachedFile)) {
23 const file = Bun.file(cachedFile);
24 return new Response(file);
25 }
26
27 // Try index.html for directory-like paths
28 if (!requestPath.includes('.')) {
29 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
30 if (existsSync(indexFile)) {
31 const file = Bun.file(indexFile);
32 return new Response(file);
33 }
34 }
35
36 return new Response('Not Found', { status: 404 });
37}
38
39// Helper to serve files from cache with HTML path rewriting for /s/ routes
40async function serveFromCacheWithRewrite(
41 did: string,
42 rkey: string,
43 filePath: string,
44 basePath: string
45) {
46 // Default to index.html if path is empty or ends with /
47 let requestPath = filePath || 'index.html';
48 if (requestPath.endsWith('/')) {
49 requestPath += 'index.html';
50 }
51
52 const cachedFile = getCachedFilePath(did, rkey, requestPath);
53
54 if (existsSync(cachedFile)) {
55 const file = Bun.file(cachedFile);
56
57 // Check if this is HTML content that needs rewriting
58 if (isHtmlContent(requestPath, file.type)) {
59 const content = await file.text();
60 const rewritten = rewriteHtmlPaths(content, basePath);
61 return new Response(rewritten, {
62 headers: {
63 'Content-Type': 'text/html; charset=utf-8',
64 },
65 });
66 }
67
68 // Non-HTML files served as-is
69 return new Response(file);
70 }
71
72 // Try index.html for directory-like paths
73 if (!requestPath.includes('.')) {
74 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
75 if (existsSync(indexFile)) {
76 const file = Bun.file(indexFile);
77 const content = await file.text();
78 const rewritten = rewriteHtmlPaths(content, basePath);
79 return new Response(rewritten, {
80 headers: {
81 'Content-Type': 'text/html; charset=utf-8',
82 },
83 });
84 }
85 }
86
87 return new Response('Not Found', { status: 404 });
88}
89
90// Helper to ensure site is cached
91async function ensureSiteCached(did: string, rkey: string): Promise<boolean> {
92 if (isCached(did, rkey)) {
93 return true;
94 }
95
96 // Fetch and cache the site
97 const record = await fetchSiteRecord(did, rkey);
98 if (!record) {
99 console.error('Site record not found', did, rkey);
100 return false;
101 }
102
103 const pdsEndpoint = await getPdsForDid(did);
104 if (!pdsEndpoint) {
105 console.error('PDS not found for DID', did);
106 return false;
107 }
108
109 try {
110 await downloadAndCacheSite(did, rkey, record, pdsEndpoint);
111 return true;
112 } catch (err) {
113 console.error('Failed to cache site', did, rkey, err);
114 return false;
115 }
116}
117
118// Route 4: Direct file serving (no DB) - /s.wisp.place/:identifier/:site/*
119app.get('/s/:identifier/:site/*', async (c) => {
120 const identifier = c.req.param('identifier');
121 const site = c.req.param('site');
122 const filePath = c.req.path.replace(`/s/${identifier}/${site}/`, '');
123
124 console.log('[Direct] Serving', { identifier, site, filePath });
125
126 // Resolve identifier to DID
127 const did = await resolveDid(identifier);
128 if (!did) {
129 return c.text('Invalid identifier', 400);
130 }
131
132 // Ensure site is cached
133 const cached = await ensureSiteCached(did, site);
134 if (!cached) {
135 return c.text('Site not found', 404);
136 }
137
138 // Serve with HTML path rewriting to handle absolute paths
139 const basePath = `/s/${identifier}/${site}/`;
140 return serveFromCacheWithRewrite(did, site, filePath, basePath);
141});
142
143// Route 3: DNS routing for custom domains - /hash.dns.wisp.place/*
144app.get('/*', async (c) => {
145 const hostname = c.req.header('host') || '';
146 const path = c.req.path.replace(/^\//, '');
147
148 console.log('[Request]', { hostname, path });
149
150 // Check if this is a DNS hash subdomain
151 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
152 if (dnsMatch) {
153 const hash = dnsMatch[1];
154 const baseDomain = dnsMatch[2];
155
156 console.log('[DNS Hash] Looking up', { hash, baseDomain });
157
158 if (baseDomain !== BASE_HOST) {
159 return c.text('Invalid base domain', 400);
160 }
161
162 const customDomain = await getCustomDomainByHash(hash);
163 if (!customDomain) {
164 return c.text('Custom domain not found or not verified', 404);
165 }
166
167 const rkey = customDomain.rkey || 'self';
168 const cached = await ensureSiteCached(customDomain.did, rkey);
169 if (!cached) {
170 return c.text('Site not found', 404);
171 }
172
173 return serveFromCache(customDomain.did, rkey, path);
174 }
175
176 // Route 2: Registered subdomains - /*.wisp.place/*
177 if (hostname.endsWith(`.${BASE_HOST}`)) {
178 const subdomain = hostname.replace(`.${BASE_HOST}`, '');
179
180 console.log('[Subdomain] Looking up', { subdomain, fullDomain: hostname });
181
182 const domainInfo = await getWispDomain(hostname);
183 if (!domainInfo) {
184 return c.text('Subdomain not registered', 404);
185 }
186
187 const rkey = domainInfo.rkey || 'self';
188 const cached = await ensureSiteCached(domainInfo.did, rkey);
189 if (!cached) {
190 return c.text('Site not found', 404);
191 }
192
193 return serveFromCache(domainInfo.did, rkey, path);
194 }
195
196 // Route 1: Custom domains - /*
197 console.log('[Custom Domain] Looking up', { hostname });
198
199 const customDomain = await getCustomDomain(hostname);
200 if (!customDomain) {
201 return c.text('Custom domain not found or not verified', 404);
202 }
203
204 const rkey = customDomain.rkey || 'self';
205 const cached = await ensureSiteCached(customDomain.did, rkey);
206 if (!cached) {
207 return c.text('Site not found', 404);
208 }
209
210 return serveFromCache(customDomain.did, rkey, path);
211});
212
213export default app;