Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1/**
2 * Core file serving logic for the hosting service
3 * Handles file retrieval, caching, redirects, and HTML rewriting
4 */
5
6import { readFile } from 'fs/promises';
7import { lookup } from 'mime-types';
8import type { Record as WispSettings } from '@wisp/lexicons/types/place/wisp/settings';
9import { shouldCompressMimeType } from '@wisp/atproto-utils/compression';
10import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, isSiteBeingCached } from './cache';
11import { getCachedFilePath, getCachedSettings } from './utils';
12import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString } from './redirects';
13import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter';
14import { generate404Page, generateDirectoryListing, siteUpdatingResponse } from './page-generators';
15import { getIndexFiles, applyCustomHeaders, fileExists } from './request-utils';
16import { getRedirectRulesFromCache, setRedirectRulesInCache } from './site-cache';
17
18/**
19 * Helper to serve files from cache (for custom domains and subdomains)
20 */
21export async function serveFromCache(
22 did: string,
23 rkey: string,
24 filePath: string,
25 fullUrl?: string,
26 headers?: Record<string, string>
27): Promise<Response> {
28 // Load settings for this site
29 const settings = await getCachedSettings(did, rkey);
30 const indexFiles = getIndexFiles(settings);
31
32 // Check for redirect rules first (_redirects wins over settings)
33 let redirectRules = getRedirectRulesFromCache(did, rkey);
34
35 if (redirectRules === undefined) {
36 // Load rules for the first time
37 redirectRules = await loadRedirectRules(did, rkey);
38 setRedirectRulesInCache(did, rkey, redirectRules);
39 }
40
41 // Apply redirect rules if any exist
42 if (redirectRules.length > 0) {
43 const requestPath = '/' + (filePath || '');
44 const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
45 const cookies = parseCookies(headers?.['cookie']);
46
47 const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
48 queryParams,
49 headers,
50 cookies,
51 });
52
53 if (redirectMatch) {
54 const { rule, targetPath, status } = redirectMatch;
55
56 // If not forced, check if the requested file exists before redirecting
57 if (!rule.force) {
58 // Build the expected file path
59 let checkPath: string = filePath || indexFiles[0] || 'index.html';
60 if (checkPath.endsWith('/')) {
61 checkPath += indexFiles[0] || 'index.html';
62 }
63
64 const cachedFile = getCachedFilePath(did, rkey, checkPath);
65 const fileExistsOnDisk = await fileExists(cachedFile);
66
67 // If file exists and redirect is not forced, serve the file normally
68 if (fileExistsOnDisk) {
69 return serveFileInternal(did, rkey, filePath, settings);
70 }
71 }
72
73 // Handle different status codes
74 if (status === 200) {
75 // Rewrite: serve different content but keep URL the same
76 // Remove leading slash for internal path resolution
77 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
78 return serveFileInternal(did, rkey, rewritePath, settings);
79 } else if (status === 301 || status === 302) {
80 // External redirect: change the URL
81 return new Response(null, {
82 status,
83 headers: {
84 'Location': targetPath,
85 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
86 },
87 });
88 } else if (status === 404) {
89 // Custom 404 page from _redirects (wins over settings.custom404)
90 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
91 const response = await serveFileInternal(did, rkey, custom404Path, settings);
92 // Override status to 404
93 return new Response(response.body, {
94 status: 404,
95 headers: response.headers,
96 });
97 }
98 }
99 }
100
101 // No redirect matched, serve normally with settings
102 return serveFileInternal(did, rkey, filePath, settings);
103}
104
105/**
106 * Internal function to serve a file (used by both normal serving and rewrites)
107 */
108export async function serveFileInternal(
109 did: string,
110 rkey: string,
111 filePath: string,
112 settings: WispSettings | null = null
113): Promise<Response> {
114 // Check if site is currently being cached - if so, return updating response
115 if (isSiteBeingCached(did, rkey)) {
116 return siteUpdatingResponse();
117 }
118
119 const indexFiles = getIndexFiles(settings);
120
121 // Normalize the request path (keep empty for root, remove trailing slash for others)
122 let requestPath = filePath || '';
123 if (requestPath.endsWith('/') && requestPath.length > 1) {
124 requestPath = requestPath.slice(0, -1);
125 }
126
127 // Check if this path is a directory first
128 const directoryPath = getCachedFilePath(did, rkey, requestPath);
129 if (await fileExists(directoryPath)) {
130 const { stat, readdir } = await import('fs/promises');
131 try {
132 const stats = await stat(directoryPath);
133 if (stats.isDirectory()) {
134 // It's a directory, try each index file in order
135 for (const indexFile of indexFiles) {
136 const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile;
137 const indexFilePath = getCachedFilePath(did, rkey, indexPath);
138 if (await fileExists(indexFilePath)) {
139 return serveFileInternal(did, rkey, indexPath, settings);
140 }
141 }
142 // No index file found - check if directory listing is enabled
143 if (settings?.directoryListing) {
144 const { stat } = await import('fs/promises');
145 const entries = await readdir(directoryPath);
146 // Filter out .meta files and other hidden files
147 const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json');
148
149 // Check which entries are directories
150 const entriesWithType = await Promise.all(
151 visibleEntries.map(async (name) => {
152 try {
153 const entryPath = `${directoryPath}/${name}`;
154 const stats = await stat(entryPath);
155 return { name, isDirectory: stats.isDirectory() };
156 } catch {
157 return { name, isDirectory: false };
158 }
159 })
160 );
161
162 const html = generateDirectoryListing(requestPath, entriesWithType);
163 return new Response(html, {
164 headers: {
165 'Content-Type': 'text/html; charset=utf-8',
166 'Cache-Control': 'public, max-age=300',
167 },
168 });
169 }
170 // Fall through to 404/SPA handling
171 }
172 } catch (err) {
173 // If stat fails, continue with normal flow
174 }
175 }
176
177 // Not a directory, try to serve as a file
178 const fileRequestPath: string = requestPath || indexFiles[0] || 'index.html';
179 const cacheKey = getCacheKey(did, rkey, fileRequestPath);
180 const cachedFile = getCachedFilePath(did, rkey, fileRequestPath);
181
182 // Check in-memory cache first
183 let content = fileCache.get(cacheKey);
184 let meta = metadataCache.get(cacheKey);
185
186 if (!content && await fileExists(cachedFile)) {
187 // Read from disk and cache
188 content = await readFile(cachedFile);
189 fileCache.set(cacheKey, content, content.length);
190
191 const metaFile = `${cachedFile}.meta`;
192 if (await fileExists(metaFile)) {
193 const metaJson = await readFile(metaFile, 'utf-8');
194 meta = JSON.parse(metaJson);
195 metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length);
196 }
197 }
198
199 if (content) {
200 // Build headers with caching
201 const headers: Record<string, string> = {};
202
203 if (meta && meta.encoding === 'gzip' && meta.mimeType) {
204 const shouldServeCompressed = shouldCompressMimeType(meta.mimeType);
205
206 if (!shouldServeCompressed) {
207 // Verify content is actually gzipped before attempting decompression
208 const isGzipped = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
209 if (isGzipped) {
210 const { gunzipSync } = await import('zlib');
211 const decompressed = gunzipSync(content);
212 headers['Content-Type'] = meta.mimeType;
213 headers['Cache-Control'] = 'public, max-age=31536000, immutable';
214 applyCustomHeaders(headers, fileRequestPath, settings);
215 return new Response(decompressed, { headers });
216 } else {
217 // Meta says gzipped but content isn't - serve as-is
218 console.warn(`File ${filePath} has gzip encoding in meta but content lacks gzip magic bytes`);
219 headers['Content-Type'] = meta.mimeType;
220 headers['Cache-Control'] = 'public, max-age=31536000, immutable';
221 applyCustomHeaders(headers, fileRequestPath, settings);
222 return new Response(content, { headers });
223 }
224 }
225
226 headers['Content-Type'] = meta.mimeType;
227 headers['Content-Encoding'] = 'gzip';
228 headers['Cache-Control'] = meta.mimeType.startsWith('text/html')
229 ? 'public, max-age=300'
230 : 'public, max-age=31536000, immutable';
231 applyCustomHeaders(headers, fileRequestPath, settings);
232 return new Response(content, { headers });
233 }
234
235 // Non-compressed files
236 const mimeType = lookup(cachedFile) || 'application/octet-stream';
237 headers['Content-Type'] = mimeType;
238 headers['Cache-Control'] = mimeType.startsWith('text/html')
239 ? 'public, max-age=300'
240 : 'public, max-age=31536000, immutable';
241 applyCustomHeaders(headers, fileRequestPath, settings);
242 return new Response(content, { headers });
243 }
244
245 // Try index files for directory-like paths
246 if (!fileRequestPath.includes('.')) {
247 for (const indexFileName of indexFiles) {
248 const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
249 const indexCacheKey = getCacheKey(did, rkey, indexPath);
250 const indexFile = getCachedFilePath(did, rkey, indexPath);
251
252 let indexContent = fileCache.get(indexCacheKey);
253 let indexMeta = metadataCache.get(indexCacheKey);
254
255 if (!indexContent && await fileExists(indexFile)) {
256 indexContent = await readFile(indexFile);
257 fileCache.set(indexCacheKey, indexContent, indexContent.length);
258
259 const indexMetaFile = `${indexFile}.meta`;
260 if (await fileExists(indexMetaFile)) {
261 const metaJson = await readFile(indexMetaFile, 'utf-8');
262 indexMeta = JSON.parse(metaJson);
263 metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
264 }
265 }
266
267 if (indexContent) {
268 const headers: Record<string, string> = {
269 'Content-Type': 'text/html; charset=utf-8',
270 'Cache-Control': 'public, max-age=300',
271 };
272
273 if (indexMeta && indexMeta.encoding === 'gzip') {
274 headers['Content-Encoding'] = 'gzip';
275 }
276
277 applyCustomHeaders(headers, indexPath, settings);
278 return new Response(indexContent, { headers });
279 }
280 }
281 }
282
283 // Try clean URLs: /about -> /about.html
284 if (settings?.cleanUrls && !fileRequestPath.includes('.')) {
285 const htmlPath = `${fileRequestPath}.html`;
286 const htmlFile = getCachedFilePath(did, rkey, htmlPath);
287 if (await fileExists(htmlFile)) {
288 return serveFileInternal(did, rkey, htmlPath, settings);
289 }
290
291 // Also try /about/index.html
292 for (const indexFileName of indexFiles) {
293 const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
294 const indexFile = getCachedFilePath(did, rkey, indexPath);
295 if (await fileExists(indexFile)) {
296 return serveFileInternal(did, rkey, indexPath, settings);
297 }
298 }
299 }
300
301 // SPA mode: serve SPA file for all non-existing routes (wins over custom404 but loses to _redirects)
302 if (settings?.spaMode) {
303 const spaFile = settings.spaMode;
304 const spaFilePath = getCachedFilePath(did, rkey, spaFile);
305 if (await fileExists(spaFilePath)) {
306 return serveFileInternal(did, rkey, spaFile, settings);
307 }
308 }
309
310 // Custom 404: serve custom 404 file if configured (wins conflict battle)
311 if (settings?.custom404) {
312 const custom404File = settings.custom404;
313 const custom404Path = getCachedFilePath(did, rkey, custom404File);
314 if (await fileExists(custom404Path)) {
315 const response: Response = await serveFileInternal(did, rkey, custom404File, settings);
316 // Override status to 404
317 return new Response(response.body, {
318 status: 404,
319 headers: response.headers,
320 });
321 }
322 }
323
324 // Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html)
325 const auto404Pages = ['404.html', 'not_found.html'];
326 for (const auto404Page of auto404Pages) {
327 const auto404Path = getCachedFilePath(did, rkey, auto404Page);
328 if (await fileExists(auto404Path)) {
329 const response: Response = await serveFileInternal(did, rkey, auto404Page, settings);
330 // Override status to 404
331 return new Response(response.body, {
332 status: 404,
333 headers: response.headers,
334 });
335 }
336 }
337
338 // Directory listing fallback: if enabled, show root directory listing on 404
339 if (settings?.directoryListing) {
340 const rootPath = getCachedFilePath(did, rkey, '');
341 if (await fileExists(rootPath)) {
342 const { stat, readdir } = await import('fs/promises');
343 try {
344 const stats = await stat(rootPath);
345 if (stats.isDirectory()) {
346 const entries = await readdir(rootPath);
347 // Filter out .meta files and metadata
348 const visibleEntries = entries.filter(entry =>
349 !entry.endsWith('.meta') && entry !== '.metadata.json'
350 );
351
352 // Check which entries are directories
353 const entriesWithType = await Promise.all(
354 visibleEntries.map(async (name) => {
355 try {
356 const entryPath = `${rootPath}/${name}`;
357 const entryStats = await stat(entryPath);
358 return { name, isDirectory: entryStats.isDirectory() };
359 } catch {
360 return { name, isDirectory: false };
361 }
362 })
363 );
364
365 const html = generateDirectoryListing('', entriesWithType);
366 return new Response(html, {
367 status: 404,
368 headers: {
369 'Content-Type': 'text/html; charset=utf-8',
370 'Cache-Control': 'public, max-age=300',
371 },
372 });
373 }
374 } catch (err) {
375 // If directory listing fails, fall through to 404
376 }
377 }
378 }
379
380 // Default styled 404 page
381 const html = generate404Page();
382 return new Response(html, {
383 status: 404,
384 headers: {
385 'Content-Type': 'text/html; charset=utf-8',
386 'Cache-Control': 'public, max-age=300',
387 },
388 });
389}
390
391/**
392 * Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes
393 */
394export async function serveFromCacheWithRewrite(
395 did: string,
396 rkey: string,
397 filePath: string,
398 basePath: string,
399 fullUrl?: string,
400 headers?: Record<string, string>
401): Promise<Response> {
402 // Load settings for this site
403 const settings = await getCachedSettings(did, rkey);
404 const indexFiles = getIndexFiles(settings);
405
406 // Check for redirect rules first (_redirects wins over settings)
407 let redirectRules = getRedirectRulesFromCache(did, rkey);
408
409 if (redirectRules === undefined) {
410 // Load rules for the first time
411 redirectRules = await loadRedirectRules(did, rkey);
412 setRedirectRulesInCache(did, rkey, redirectRules);
413 }
414
415 // Apply redirect rules if any exist
416 if (redirectRules.length > 0) {
417 const requestPath = '/' + (filePath || '');
418 const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
419 const cookies = parseCookies(headers?.['cookie']);
420
421 const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
422 queryParams,
423 headers,
424 cookies,
425 });
426
427 if (redirectMatch) {
428 const { rule, targetPath, status } = redirectMatch;
429
430 // If not forced, check if the requested file exists before redirecting
431 if (!rule.force) {
432 // Build the expected file path
433 let checkPath: string = filePath || indexFiles[0] || 'index.html';
434 if (checkPath.endsWith('/')) {
435 checkPath += indexFiles[0] || 'index.html';
436 }
437
438 const cachedFile = getCachedFilePath(did, rkey, checkPath);
439 const fileExistsOnDisk = await fileExists(cachedFile);
440
441 // If file exists and redirect is not forced, serve the file normally
442 if (fileExistsOnDisk) {
443 return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
444 }
445 }
446
447 // Handle different status codes
448 if (status === 200) {
449 // Rewrite: serve different content but keep URL the same
450 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
451 return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath, settings);
452 } else if (status === 301 || status === 302) {
453 // External redirect: change the URL
454 // For sites.wisp.place, we need to adjust the target path to include the base path
455 // unless it's an absolute URL
456 let redirectTarget = targetPath;
457 if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) {
458 redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath);
459 }
460 return new Response(null, {
461 status,
462 headers: {
463 'Location': redirectTarget,
464 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
465 },
466 });
467 } else if (status === 404) {
468 // Custom 404 page from _redirects (wins over settings.custom404)
469 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
470 const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath, settings);
471 // Override status to 404
472 return new Response(response.body, {
473 status: 404,
474 headers: response.headers,
475 });
476 }
477 }
478 }
479
480 // No redirect matched, serve normally with settings
481 return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
482}
483
484/**
485 * Internal function to serve a file with rewriting
486 */
487export async function serveFileInternalWithRewrite(
488 did: string,
489 rkey: string,
490 filePath: string,
491 basePath: string,
492 settings: WispSettings | null = null
493): Promise<Response> {
494 // Check if site is currently being cached - if so, return updating response
495 if (isSiteBeingCached(did, rkey)) {
496 return siteUpdatingResponse();
497 }
498
499 const indexFiles = getIndexFiles(settings);
500
501 // Normalize the request path (keep empty for root, remove trailing slash for others)
502 let requestPath = filePath || '';
503 if (requestPath.endsWith('/') && requestPath.length > 1) {
504 requestPath = requestPath.slice(0, -1);
505 }
506
507 // Check if this path is a directory first
508 const directoryPath = getCachedFilePath(did, rkey, requestPath);
509 if (await fileExists(directoryPath)) {
510 const { stat, readdir } = await import('fs/promises');
511 try {
512 const stats = await stat(directoryPath);
513 if (stats.isDirectory()) {
514 // It's a directory, try each index file in order
515 for (const indexFile of indexFiles) {
516 const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile;
517 const indexFilePath = getCachedFilePath(did, rkey, indexPath);
518 if (await fileExists(indexFilePath)) {
519 return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
520 }
521 }
522 // No index file found - check if directory listing is enabled
523 if (settings?.directoryListing) {
524 const { stat } = await import('fs/promises');
525 const entries = await readdir(directoryPath);
526 // Filter out .meta files and other hidden files
527 const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json');
528
529 // Check which entries are directories
530 const entriesWithType = await Promise.all(
531 visibleEntries.map(async (name) => {
532 try {
533 const entryPath = `${directoryPath}/${name}`;
534 const stats = await stat(entryPath);
535 return { name, isDirectory: stats.isDirectory() };
536 } catch {
537 return { name, isDirectory: false };
538 }
539 })
540 );
541
542 const html = generateDirectoryListing(requestPath, entriesWithType);
543 return new Response(html, {
544 headers: {
545 'Content-Type': 'text/html; charset=utf-8',
546 'Cache-Control': 'public, max-age=300',
547 },
548 });
549 }
550 // Fall through to 404/SPA handling
551 }
552 } catch (err) {
553 // If stat fails, continue with normal flow
554 }
555 }
556
557 // Not a directory, try to serve as a file
558 const fileRequestPath: string = requestPath || indexFiles[0] || 'index.html';
559 const cacheKey = getCacheKey(did, rkey, fileRequestPath);
560 const cachedFile = getCachedFilePath(did, rkey, fileRequestPath);
561
562 // Check for rewritten HTML in cache first (if it's HTML)
563 const mimeTypeGuess = lookup(fileRequestPath) || 'application/octet-stream';
564 if (isHtmlContent(fileRequestPath, mimeTypeGuess)) {
565 const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
566 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
567 if (rewrittenContent) {
568 const headers: Record<string, string> = {
569 'Content-Type': 'text/html; charset=utf-8',
570 'Content-Encoding': 'gzip',
571 'Cache-Control': 'public, max-age=300',
572 };
573 applyCustomHeaders(headers, fileRequestPath, settings);
574 return new Response(rewrittenContent, { headers });
575 }
576 }
577
578 // Check in-memory file cache
579 let content = fileCache.get(cacheKey);
580 let meta = metadataCache.get(cacheKey);
581
582 if (!content && await fileExists(cachedFile)) {
583 // Read from disk and cache
584 content = await readFile(cachedFile);
585 fileCache.set(cacheKey, content, content.length);
586
587 const metaFile = `${cachedFile}.meta`;
588 if (await fileExists(metaFile)) {
589 const metaJson = await readFile(metaFile, 'utf-8');
590 meta = JSON.parse(metaJson);
591 metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length);
592 }
593 }
594
595 if (content) {
596 const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream';
597 const isGzipped = meta?.encoding === 'gzip';
598
599 // Check if this is HTML content that needs rewriting
600 if (isHtmlContent(fileRequestPath, mimeType)) {
601 let htmlContent: string;
602 if (isGzipped) {
603 // Verify content is actually gzipped
604 const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
605 if (hasGzipMagic) {
606 const { gunzipSync } = await import('zlib');
607 htmlContent = gunzipSync(content).toString('utf-8');
608 } else {
609 console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
610 htmlContent = content.toString('utf-8');
611 }
612 } else {
613 htmlContent = content.toString('utf-8');
614 }
615 const rewritten = rewriteHtmlPaths(htmlContent, basePath, fileRequestPath);
616
617 // Recompress and cache the rewritten HTML
618 const { gzipSync } = await import('zlib');
619 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
620
621 const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
622 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
623
624 const htmlHeaders: Record<string, string> = {
625 'Content-Type': 'text/html; charset=utf-8',
626 'Content-Encoding': 'gzip',
627 'Cache-Control': 'public, max-age=300',
628 };
629 applyCustomHeaders(htmlHeaders, fileRequestPath, settings);
630 return new Response(recompressed, { headers: htmlHeaders });
631 }
632
633 // Non-HTML files: serve as-is
634 const headers: Record<string, string> = {
635 'Content-Type': mimeType,
636 'Cache-Control': 'public, max-age=31536000, immutable',
637 };
638
639 if (isGzipped) {
640 const shouldServeCompressed = shouldCompressMimeType(mimeType);
641 if (!shouldServeCompressed) {
642 // Verify content is actually gzipped
643 const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
644 if (hasGzipMagic) {
645 const { gunzipSync } = await import('zlib');
646 const decompressed = gunzipSync(content);
647 applyCustomHeaders(headers, fileRequestPath, settings);
648 return new Response(decompressed, { headers });
649 } else {
650 console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
651 applyCustomHeaders(headers, fileRequestPath, settings);
652 return new Response(content, { headers });
653 }
654 }
655 headers['Content-Encoding'] = 'gzip';
656 }
657
658 applyCustomHeaders(headers, fileRequestPath, settings);
659 return new Response(content, { headers });
660 }
661
662 // Try index files for directory-like paths
663 if (!fileRequestPath.includes('.')) {
664 for (const indexFileName of indexFiles) {
665 const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
666 const indexCacheKey = getCacheKey(did, rkey, indexPath);
667 const indexFile = getCachedFilePath(did, rkey, indexPath);
668
669 // Check for rewritten index file in cache
670 const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`);
671 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
672 if (rewrittenContent) {
673 const headers: Record<string, string> = {
674 'Content-Type': 'text/html; charset=utf-8',
675 'Content-Encoding': 'gzip',
676 'Cache-Control': 'public, max-age=300',
677 };
678 applyCustomHeaders(headers, indexPath, settings);
679 return new Response(rewrittenContent, { headers });
680 }
681
682 let indexContent = fileCache.get(indexCacheKey);
683 let indexMeta = metadataCache.get(indexCacheKey);
684
685 if (!indexContent && await fileExists(indexFile)) {
686 indexContent = await readFile(indexFile);
687 fileCache.set(indexCacheKey, indexContent, indexContent.length);
688
689 const indexMetaFile = `${indexFile}.meta`;
690 if (await fileExists(indexMetaFile)) {
691 const metaJson = await readFile(indexMetaFile, 'utf-8');
692 indexMeta = JSON.parse(metaJson);
693 metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
694 }
695 }
696
697 if (indexContent) {
698 const isGzipped = indexMeta?.encoding === 'gzip';
699
700 let htmlContent: string;
701 if (isGzipped) {
702 // Verify content is actually gzipped
703 const hasGzipMagic = indexContent.length >= 2 && indexContent[0] === 0x1f && indexContent[1] === 0x8b;
704 if (hasGzipMagic) {
705 const { gunzipSync } = await import('zlib');
706 htmlContent = gunzipSync(indexContent).toString('utf-8');
707 } else {
708 console.warn(`Index file marked as gzipped but lacks magic bytes, serving as-is`);
709 htmlContent = indexContent.toString('utf-8');
710 }
711 } else {
712 htmlContent = indexContent.toString('utf-8');
713 }
714 const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath);
715
716 const { gzipSync } = await import('zlib');
717 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
718
719 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
720
721 const headers: Record<string, string> = {
722 'Content-Type': 'text/html; charset=utf-8',
723 'Content-Encoding': 'gzip',
724 'Cache-Control': 'public, max-age=300',
725 };
726 applyCustomHeaders(headers, indexPath, settings);
727 return new Response(recompressed, { headers });
728 }
729 }
730 }
731
732 // Try clean URLs: /about -> /about.html
733 if (settings?.cleanUrls && !fileRequestPath.includes('.')) {
734 const htmlPath = `${fileRequestPath}.html`;
735 const htmlFile = getCachedFilePath(did, rkey, htmlPath);
736 if (await fileExists(htmlFile)) {
737 return serveFileInternalWithRewrite(did, rkey, htmlPath, basePath, settings);
738 }
739
740 // Also try /about/index.html
741 for (const indexFileName of indexFiles) {
742 const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
743 const indexFile = getCachedFilePath(did, rkey, indexPath);
744 if (await fileExists(indexFile)) {
745 return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
746 }
747 }
748 }
749
750 // SPA mode: serve SPA file for all non-existing routes
751 if (settings?.spaMode) {
752 const spaFile = settings.spaMode;
753 const spaFilePath = getCachedFilePath(did, rkey, spaFile);
754 if (await fileExists(spaFilePath)) {
755 return serveFileInternalWithRewrite(did, rkey, spaFile, basePath, settings);
756 }
757 }
758
759 // Custom 404: serve custom 404 file if configured (wins conflict battle)
760 if (settings?.custom404) {
761 const custom404File = settings.custom404;
762 const custom404Path = getCachedFilePath(did, rkey, custom404File);
763 if (await fileExists(custom404Path)) {
764 const response: Response = await serveFileInternalWithRewrite(did, rkey, custom404File, basePath, settings);
765 // Override status to 404
766 return new Response(response.body, {
767 status: 404,
768 headers: response.headers,
769 });
770 }
771 }
772
773 // Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html)
774 const auto404Pages = ['404.html', 'not_found.html'];
775 for (const auto404Page of auto404Pages) {
776 const auto404Path = getCachedFilePath(did, rkey, auto404Page);
777 if (await fileExists(auto404Path)) {
778 const response: Response = await serveFileInternalWithRewrite(did, rkey, auto404Page, basePath, settings);
779 // Override status to 404
780 return new Response(response.body, {
781 status: 404,
782 headers: response.headers,
783 });
784 }
785 }
786
787 // Directory listing fallback: if enabled, show root directory listing on 404
788 if (settings?.directoryListing) {
789 const rootPath = getCachedFilePath(did, rkey, '');
790 if (await fileExists(rootPath)) {
791 const { stat, readdir } = await import('fs/promises');
792 try {
793 const stats = await stat(rootPath);
794 if (stats.isDirectory()) {
795 const entries = await readdir(rootPath);
796 // Filter out .meta files and metadata
797 const visibleEntries = entries.filter(entry =>
798 !entry.endsWith('.meta') && entry !== '.metadata.json'
799 );
800
801 // Check which entries are directories
802 const entriesWithType = await Promise.all(
803 visibleEntries.map(async (name) => {
804 try {
805 const entryPath = `${rootPath}/${name}`;
806 const entryStats = await stat(entryPath);
807 return { name, isDirectory: entryStats.isDirectory() };
808 } catch {
809 return { name, isDirectory: false };
810 }
811 })
812 );
813
814 const html = generateDirectoryListing('', entriesWithType);
815 return new Response(html, {
816 status: 404,
817 headers: {
818 'Content-Type': 'text/html; charset=utf-8',
819 'Cache-Control': 'public, max-age=300',
820 },
821 });
822 }
823 } catch (err) {
824 // If directory listing fails, fall through to 404
825 }
826 }
827 }
828
829 // Default styled 404 page
830 const html = generate404Page();
831 return new Response(html, {
832 status: 404,
833 headers: {
834 'Content-Type': 'text/html; charset=utf-8',
835 'Cache-Control': 'public, max-age=300',
836 },
837 });
838}
839