Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
at main 32 kB view raw
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