Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

Reduce S3 reads: replace storageExists, cache 404 negatives, use manifest for directory listings

+595 -492
+1
apps/hosting-service/package.json
··· 15 15 "@atproto/sync": "^0.1.36", 16 16 "@atproto/xrpc": "^0.7.5", 17 17 "@wispplace/atproto-utils": "workspace:*", 18 + "@wispplace/page-generators": "workspace:*", 18 19 "@wispplace/constants": "workspace:*", 19 20 "@wispplace/database": "workspace:*", 20 21 "@wispplace/fs-utils": "workspace:*",
+1
apps/hosting-service/src/lib/cache-invalidation.ts
··· 99 99 // Clear in-memory caches for this site 100 100 cache.delete('redirectRules', `${did}:${rkey}`); 101 101 cache.delete('settings', `${did}:${rkey}`); 102 + cache.deletePrefix('siteFiles', `${did}:${rkey}:`); 102 103 } catch (err) { 103 104 console.error('[CacheInvalidation] Error processing message:', err); 104 105 }
+18 -1
apps/hosting-service/src/lib/cache-manager.ts
··· 145 145 } 146 146 } 147 147 148 + deletePrefix(ns: NS, prefix: string): void { 149 + const map = this.namespaces.get(ns); 150 + const st = this.stats.get(ns); 151 + if (!map || !st) return; 152 + 153 + for (const [key, entry] of map) { 154 + if (key.startsWith(prefix)) { 155 + map.delete(key); 156 + st.sizeBytes -= entry.size; 157 + } 158 + } 159 + st.entries = map.size; 160 + } 161 + 148 162 clear(ns: NS): void { 149 163 const map = this.namespaces.get(ns); 150 164 const st = this.stats.get(ns); ··· 197 211 198 212 // ── Singleton ──────────────────────────────────────────────────────────── 199 213 200 - type CacheNamespace = 'domains' | 'customDomains' | 'settings' | 'handles' | 'redirectRules'; 214 + type CacheNamespace = 'domains' | 'customDomains' | 'settings' | 'handles' | 'redirectRules' | 'siteFiles'; 201 215 202 216 export const cache = new CacheManager<CacheNamespace>({ 203 217 domains: { ttl: 5 * 60_000, maxEntries: 5000 }, ··· 205 219 settings: { ttl: 5 * 60_000, maxEntries: 1000 }, 206 220 handles: { ttl: 10 * 60_000, maxEntries: 5000 }, 207 221 redirectRules: { maxEntries: 1000, maxSize: 10 * 1024 * 1024, estimateSize: (v) => (v as unknown[]).length * 100 }, 222 + // Negative-result cache for per-site fallback files (SPA, custom 404, auto-detected 404 pages). 223 + // Stores null when a file is confirmed absent so repeated 404 responses don't re-hit S3. 224 + siteFiles: { ttl: 5 * 60_000, maxEntries: 10_000 }, 208 225 });
+123 -67
apps/hosting-service/src/lib/file-serving.ts
··· 61 61 return `${did}/${rkey}/${normalized}`; 62 62 } 63 63 64 - async function storageExists(did: string, rkey: string, filePath: string): Promise<boolean> { 65 - const key = buildStorageKey(did, rkey, filePath); 66 - return storage.exists(key); 64 + /** 65 + * Fetch a per-site fallback file (SPA, custom 404, auto-detected 404 pages), 66 + * caching null results so repeated 404 responses don't re-hit S3 for files 67 + * that don't exist on the site. 68 + */ 69 + async function getFallbackFile( 70 + did: string, 71 + rkey: string, 72 + filePath: string, 73 + trace?: RequestTrace | null 74 + ): Promise<FileStorageResult | null> { 75 + const cacheKey = `${did}:${rkey}:${filePath}`; 76 + // null in the cache means we've confirmed this file doesn't exist 77 + const negativeCached = cache.get<null>('siteFiles', cacheKey); 78 + if (negativeCached === null) return null; 79 + 80 + const result = await span(trace, `storage:${filePath}`, () => getFileWithMetadata(did, rkey, filePath)); 81 + if (result === null) { 82 + cache.set('siteFiles', cacheKey, null); 83 + } 84 + return result; 85 + } 86 + 87 + /** 88 + * Same as getFallbackFile but prefers pre-rewritten HTML (for the WithRewrite path). 89 + */ 90 + async function getFallbackFileForRequest( 91 + did: string, 92 + rkey: string, 93 + filePath: string, 94 + trace?: RequestTrace | null 95 + ): Promise<FileForRequestResult | null> { 96 + const cacheKey = `${did}:${rkey}:${filePath}`; 97 + const negativeCached = cache.get<null>('siteFiles', cacheKey); 98 + if (negativeCached === null) return null; 99 + 100 + const result = await span(trace, `storage:${filePath}`, () => getFileForRequest(did, rkey, filePath, true)); 101 + if (result === null) { 102 + cache.set('siteFiles', cacheKey, null); 103 + } 104 + return result; 67 105 } 68 106 69 107 function shouldServeUpdatingPage(requestHeaders?: Record<string, string>): boolean { ··· 93 131 async function listDirectoryEntries( 94 132 did: string, 95 133 rkey: string, 96 - requestPath: string 134 + requestPath: string, 135 + manifestPaths?: string[] | null 97 136 ): Promise<Array<{ name: string; isDirectory: boolean }>> { 98 - const prefix = buildStorageKey(did, rkey, requestPath ? `${requestPath}/` : ''); 99 137 const entries = new Map<string, boolean>(); 100 138 101 - for await (const key of storage.listKeys(prefix)) { 102 - const relative = key.slice(prefix.length); 103 - if (!relative) continue; 104 - if (relative.startsWith('.rewritten/')) continue; 139 + if (manifestPaths != null) { 140 + // Use the DB manifest — no disk or S3 I/O 141 + const prefix = requestPath ? `${requestPath}/` : ''; 142 + for (const filePath of manifestPaths) { 143 + if (prefix && !filePath.startsWith(prefix)) continue; 144 + const relative = prefix ? filePath.slice(prefix.length) : filePath; 145 + if (!relative) continue; 146 + if (relative.startsWith('.rewritten/')) continue; 105 147 106 - const [name, ...rest] = relative.split('/'); 107 - if (!name || name === '.metadata.json' || name.endsWith('.meta')) continue; 148 + const [name, ...rest] = relative.split('/'); 149 + if (!name || name === '.metadata.json' || name.endsWith('.meta')) continue; 108 150 109 - const isDirectory = rest.length > 0; 110 - const existing = entries.get(name); 111 - if (existing === undefined || (isDirectory && !existing)) { 112 - entries.set(name, isDirectory); 151 + const isDirectory = rest.length > 0; 152 + const existing = entries.get(name); 153 + if (existing === undefined || (isDirectory && !existing)) { 154 + entries.set(name, isDirectory); 155 + } 156 + } 157 + } else { 158 + // Fallback: list from storage (only reached when site not yet in DB) 159 + const prefix = buildStorageKey(did, rkey, requestPath ? `${requestPath}/` : ''); 160 + for await (const key of storage.listKeys(prefix)) { 161 + const relative = key.slice(prefix.length); 162 + if (!relative) continue; 163 + if (relative.startsWith('.rewritten/')) continue; 164 + 165 + const [name, ...rest] = relative.split('/'); 166 + if (!name || name === '.metadata.json' || name.endsWith('.meta')) continue; 167 + 168 + const isDirectory = rest.length > 0; 169 + const existing = entries.get(name); 170 + if (existing === undefined || (isDirectory && !existing)) { 171 + entries.set(name, isDirectory); 172 + } 113 173 } 114 174 } 115 175 ··· 292 352 checkPath += indexFiles[0] || 'index.html'; 293 353 } 294 354 295 - const fileExistsInStorage = await storageExists(did, rkey, checkPath); 355 + const fileInStorage = await span(trace, `storage:${checkPath}`, () => getFileWithMetadata(did, rkey, checkPath)); 296 356 297 357 // If file exists and redirect is not forced, serve the file normally 298 - if (fileExistsInStorage) { 358 + if (fileInStorage) { 299 359 const response = await serveFileInternal(did, rkey, filePath, settings, headers, trace); 300 360 logTrace(trace, filePath || '/', logger); 301 361 return response; ··· 413 473 414 474 // Index not found - check if we need directory listing 415 475 if (settings?.directoryListing) { 416 - const directoryEntries = await listDirectoryEntries(did, rkey, requestPath); 476 + const fileCids = await getExpectedFileCids(); 477 + const directoryEntries = await listDirectoryEntries(did, rkey, requestPath, fileCids ? Object.keys(fileCids) : null); 417 478 if (directoryEntries.length > 0) { 418 479 const missResponse = await maybeReturnStorageMiss(); 419 480 if (missResponse) return missResponse; ··· 455 516 // Try clean URLs: /about -> /about.html 456 517 if (settings?.cleanUrls && !hasFileExtension(fileRequestPath)) { 457 518 const htmlPath = `${fileRequestPath}.html`; 458 - if (await storageExists(did, rkey, htmlPath)) { 459 - return serveFileInternal(did, rkey, htmlPath, settings, requestHeaders, trace); 519 + const htmlResult = await span(trace, `storage:${htmlPath}`, () => getFileWithMetadata(did, rkey, htmlPath)); 520 + if (htmlResult) { 521 + return buildResponseFromStorageResult(htmlResult, htmlPath, settings, requestHeaders); 460 522 } 461 523 await markExpectedMiss(htmlPath); 462 524 463 525 // Also try /about/index.html 464 526 for (const indexFileName of indexFiles) { 465 527 const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName; 466 - if (await storageExists(did, rkey, indexPath)) { 467 - return serveFileInternal(did, rkey, indexPath, settings, requestHeaders, trace); 528 + const indexResult = await span(trace, `storage:${indexPath}`, () => getFileWithMetadata(did, rkey, indexPath)); 529 + if (indexResult) { 530 + return buildResponseFromStorageResult(indexResult, indexPath, settings, requestHeaders); 468 531 } 469 532 await markExpectedMiss(indexPath); 470 533 } ··· 473 536 // SPA mode: serve SPA file for all non-existing routes (wins over custom404 but loses to _redirects) 474 537 if (settings?.spaMode) { 475 538 const spaFile = settings.spaMode; 476 - if (await storageExists(did, rkey, spaFile)) { 477 - return serveFileInternal(did, rkey, spaFile, settings, requestHeaders, trace); 539 + const spaResult = await getFallbackFile(did, rkey, spaFile, trace); 540 + if (spaResult) { 541 + return buildResponseFromStorageResult(spaResult, spaFile, settings, requestHeaders); 478 542 } 479 543 await markExpectedMiss(spaFile); 480 544 } ··· 482 546 // Custom 404: serve custom 404 file if configured (wins conflict battle) 483 547 if (settings?.custom404) { 484 548 const custom404File = settings.custom404; 485 - if (await storageExists(did, rkey, custom404File)) { 486 - const response: Response = await serveFileInternal(did, rkey, custom404File, settings, requestHeaders, trace); 487 - // Override status to 404 488 - return new Response(response.body, { 489 - status: 404, 490 - headers: response.headers, 491 - }); 549 + const custom404Result = await getFallbackFile(did, rkey, custom404File, trace); 550 + if (custom404Result) { 551 + const response = buildResponseFromStorageResult(custom404Result, custom404File, settings, requestHeaders); 552 + return new Response(response.body, { status: 404, headers: response.headers }); 492 553 } 493 554 await markExpectedMiss(custom404File); 494 555 } 495 556 496 557 // Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html) 497 - const auto404Pages = ['404.html', 'not_found.html']; 498 - for (const auto404Page of auto404Pages) { 499 - if (await storageExists(did, rkey, auto404Page)) { 500 - const response: Response = await serveFileInternal(did, rkey, auto404Page, settings, requestHeaders, trace); 501 - // Override status to 404 502 - return new Response(response.body, { 503 - status: 404, 504 - headers: response.headers, 505 - }); 558 + for (const auto404Page of ['404.html', 'not_found.html']) { 559 + const auto404Result = await getFallbackFile(did, rkey, auto404Page, trace); 560 + if (auto404Result) { 561 + const response = buildResponseFromStorageResult(auto404Result, auto404Page, settings, requestHeaders); 562 + return new Response(response.body, { status: 404, headers: response.headers }); 506 563 } 507 564 await markExpectedMiss(auto404Page); 508 565 } 509 566 510 567 // Directory listing fallback: if enabled, show root directory listing on 404 511 568 if (settings?.directoryListing) { 512 - const rootEntries = await listDirectoryEntries(did, rkey, ''); 569 + const fileCids = await getExpectedFileCids(); 570 + const rootEntries = await listDirectoryEntries(did, rkey, '', fileCids ? Object.keys(fileCids) : null); 513 571 if (rootEntries.length > 0) { 514 572 const missResponse = await maybeReturnStorageMiss(); 515 573 if (missResponse) return missResponse; ··· 598 656 checkPath += indexFiles[0] || 'index.html'; 599 657 } 600 658 601 - const fileExistsInStorage = await storageExists(did, rkey, checkPath); 659 + const fileInStorage = await span(trace, `storage:${checkPath}`, () => getFileWithMetadata(did, rkey, checkPath)); 602 660 603 661 // If file exists and redirect is not forced, serve the file normally 604 - if (fileExistsInStorage) { 662 + if (fileInStorage) { 605 663 const response = await serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings, headers, trace); 606 664 logTrace(trace, filePath || '/', logger); 607 665 return response; ··· 737 795 738 796 // Index not found - check if we need directory listing 739 797 if (settings?.directoryListing) { 740 - const directoryEntries = await listDirectoryEntries(did, rkey, requestPath); 798 + const fileCids = await getExpectedFileCids(); 799 + const directoryEntries = await listDirectoryEntries(did, rkey, requestPath, fileCids ? Object.keys(fileCids) : null); 741 800 if (directoryEntries.length > 0) { 742 801 const missResponse = await maybeReturnStorageMiss(); 743 802 if (missResponse) return missResponse; ··· 777 836 // Try clean URLs: /about -> /about.html 778 837 if (settings?.cleanUrls && !hasFileExtension(fileRequestPath)) { 779 838 const htmlPath = `${fileRequestPath}.html`; 780 - if (await storageExists(did, rkey, htmlPath)) { 781 - return serveFileInternalWithRewrite(did, rkey, htmlPath, basePath, settings, requestHeaders, trace); 839 + const htmlResult = await span(trace, `storage:${htmlPath}`, () => getFileForRequest(did, rkey, htmlPath, true)); 840 + if (htmlResult) { 841 + return buildResponse(htmlResult); 782 842 } 783 843 await markExpectedMiss(htmlPath); 784 844 785 845 // Also try /about/index.html 786 846 for (const indexFileName of indexFiles) { 787 847 const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName; 788 - if (await storageExists(did, rkey, indexPath)) { 789 - return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings, requestHeaders, trace); 848 + const indexResult = await span(trace, `storage:${indexPath}`, () => getFileForRequest(did, rkey, indexPath, true)); 849 + if (indexResult) { 850 + return buildResponse(indexResult); 790 851 } 791 852 await markExpectedMiss(indexPath); 792 853 } ··· 795 856 // SPA mode: serve SPA file for all non-existing routes 796 857 if (settings?.spaMode) { 797 858 const spaFile = settings.spaMode; 798 - if (await storageExists(did, rkey, spaFile)) { 799 - return serveFileInternalWithRewrite(did, rkey, spaFile, basePath, settings, requestHeaders, trace); 859 + const spaResult = await getFallbackFileForRequest(did, rkey, spaFile, trace); 860 + if (spaResult) { 861 + return buildResponse(spaResult); 800 862 } 801 863 await markExpectedMiss(spaFile); 802 864 } ··· 804 866 // Custom 404: serve custom 404 file if configured (wins conflict battle) 805 867 if (settings?.custom404) { 806 868 const custom404File = settings.custom404; 807 - if (await storageExists(did, rkey, custom404File)) { 808 - const response: Response = await serveFileInternalWithRewrite(did, rkey, custom404File, basePath, settings, requestHeaders, trace); 809 - // Override status to 404 810 - return new Response(response.body, { 811 - status: 404, 812 - headers: response.headers, 813 - }); 869 + const custom404Result = await getFallbackFileForRequest(did, rkey, custom404File, trace); 870 + if (custom404Result) { 871 + const response = buildResponse(custom404Result); 872 + return new Response(response.body, { status: 404, headers: response.headers }); 814 873 } 815 874 await markExpectedMiss(custom404File); 816 875 } 817 876 818 877 // Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html) 819 - const auto404Pages = ['404.html', 'not_found.html']; 820 - for (const auto404Page of auto404Pages) { 821 - if (await storageExists(did, rkey, auto404Page)) { 822 - const response: Response = await serveFileInternalWithRewrite(did, rkey, auto404Page, basePath, settings, requestHeaders, trace); 823 - // Override status to 404 824 - return new Response(response.body, { 825 - status: 404, 826 - headers: response.headers, 827 - }); 878 + for (const auto404Page of ['404.html', 'not_found.html']) { 879 + const auto404Result = await getFallbackFileForRequest(did, rkey, auto404Page, trace); 880 + if (auto404Result) { 881 + const response = buildResponse(auto404Result); 882 + return new Response(response.body, { status: 404, headers: response.headers }); 828 883 } 829 884 await markExpectedMiss(auto404Page); 830 885 } 831 886 832 887 // Directory listing fallback: if enabled, show root directory listing on 404 833 888 if (settings?.directoryListing) { 834 - const rootEntries = await listDirectoryEntries(did, rkey, ''); 889 + const fileCids = await getExpectedFileCids(); 890 + const rootEntries = await listDirectoryEntries(did, rkey, '', fileCids ? Object.keys(fileCids) : null); 835 891 if (rootEntries.length > 0) { 836 892 const missResponse = await maybeReturnStorageMiss(); 837 893 if (missResponse) return missResponse;
+1 -362
apps/hosting-service/src/lib/page-generators.ts
··· 1 - /** 2 - * HTML page generation utilities for hosting service 3 - * Generates 404 pages, directory listings, and updating pages 4 - */ 5 - 6 - /** 7 - * Generate 404 page HTML 8 - */ 9 - export function generate404Page(): string { 10 - const html = `<!DOCTYPE html> 11 - <html> 12 - <head> 13 - <meta charset="utf-8"> 14 - <meta name="viewport" content="width=device-width, initial-scale=1"> 15 - <title>404 - Not Found</title> 16 - <style> 17 - @media (prefers-color-scheme: light) { 18 - :root { 19 - /* Warm beige background */ 20 - --background: oklch(0.90 0.012 35); 21 - /* Very dark brown text */ 22 - --foreground: oklch(0.18 0.01 30); 23 - --border: oklch(0.75 0.015 30); 24 - /* Bright pink accent for links */ 25 - --accent: oklch(0.78 0.15 345); 26 - } 27 - } 28 - @media (prefers-color-scheme: dark) { 29 - :root { 30 - /* Slate violet background */ 31 - --background: oklch(0.23 0.015 285); 32 - /* Light gray text */ 33 - --foreground: oklch(0.90 0.005 285); 34 - /* Subtle borders */ 35 - --border: oklch(0.38 0.02 285); 36 - /* Soft pink accent */ 37 - --accent: oklch(0.85 0.08 5); 38 - } 39 - } 40 - body { 41 - font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; 42 - background: var(--background); 43 - color: var(--foreground); 44 - padding: 2rem; 45 - max-width: 800px; 46 - margin: 0 auto; 47 - display: flex; 48 - flex-direction: column; 49 - min-height: 100vh; 50 - justify-content: center; 51 - align-items: center; 52 - text-align: center; 53 - } 54 - h1 { 55 - font-size: 6rem; 56 - margin: 0; 57 - font-weight: 700; 58 - line-height: 1; 59 - } 60 - h2 { 61 - font-size: 1.5rem; 62 - margin: 1rem 0 2rem; 63 - font-weight: 400; 64 - opacity: 0.8; 65 - } 66 - p { 67 - font-size: 1rem; 68 - opacity: 0.7; 69 - margin-bottom: 2rem; 70 - } 71 - a { 72 - color: var(--accent); 73 - text-decoration: none; 74 - font-size: 1rem; 75 - } 76 - a:hover { 77 - text-decoration: underline; 78 - } 79 - footer { 80 - margin-top: 2rem; 81 - padding-top: 1.5rem; 82 - border-top: 1px solid var(--border); 83 - text-align: center; 84 - font-size: 0.875rem; 85 - opacity: 0.7; 86 - color: var(--foreground); 87 - } 88 - footer a { 89 - color: var(--accent); 90 - text-decoration: none; 91 - display: inline; 92 - } 93 - footer a:hover { 94 - text-decoration: underline; 95 - } 96 - </style> 97 - </head> 98 - <body> 99 - <div> 100 - <h1>404</h1> 101 - <h2>Page not found</h2> 102 - <p>The page you're looking for doesn't exist.</p> 103 - <a href="/">← Back to home</a> 104 - </div> 105 - <footer> 106 - Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a> 107 - </footer> 108 - </body> 109 - </html>`; 110 - return html; 111 - } 112 - 113 - /** 114 - * Generate directory listing HTML 115 - */ 116 - export function generateDirectoryListing(path: string, entries: Array<{name: string, isDirectory: boolean}>): string { 117 - const title = path || 'Index'; 118 - 119 - // Sort: directories first, then files, alphabetically within each group 120 - const sortedEntries = [...entries].sort((a, b) => { 121 - if (a.isDirectory && !b.isDirectory) return -1; 122 - if (!a.isDirectory && b.isDirectory) return 1; 123 - return a.name.localeCompare(b.name); 124 - }); 125 - 126 - const html = `<!DOCTYPE html> 127 - <html> 128 - <head> 129 - <meta charset="utf-8"> 130 - <meta name="viewport" content="width=device-width, initial-scale=1"> 131 - <title>Index of /${path}</title> 132 - <style> 133 - @media (prefers-color-scheme: light) { 134 - :root { 135 - /* Warm beige background */ 136 - --background: oklch(0.90 0.012 35); 137 - /* Very dark brown text */ 138 - --foreground: oklch(0.18 0.01 30); 139 - --border: oklch(0.75 0.015 30); 140 - /* Bright pink accent for links */ 141 - --accent: oklch(0.78 0.15 345); 142 - /* Lavender for folders */ 143 - --folder: oklch(0.60 0.12 295); 144 - --icon: oklch(0.28 0.01 30); 145 - } 146 - } 147 - @media (prefers-color-scheme: dark) { 148 - :root { 149 - /* Slate violet background */ 150 - --background: oklch(0.23 0.015 285); 151 - /* Light gray text */ 152 - --foreground: oklch(0.90 0.005 285); 153 - /* Subtle borders */ 154 - --border: oklch(0.38 0.02 285); 155 - /* Soft pink accent */ 156 - --accent: oklch(0.85 0.08 5); 157 - /* Lavender for folders */ 158 - --folder: oklch(0.70 0.10 295); 159 - --icon: oklch(0.85 0.005 285); 160 - } 161 - } 162 - body { 163 - font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; 164 - background: var(--background); 165 - color: var(--foreground); 166 - padding: 2rem; 167 - max-width: 800px; 168 - margin: 0 auto; 169 - } 170 - h1 { 171 - font-size: 1.5rem; 172 - margin-bottom: 2rem; 173 - padding-bottom: 0.5rem; 174 - border-bottom: 1px solid var(--border); 175 - } 176 - ul { 177 - list-style: none; 178 - padding: 0; 179 - } 180 - li { 181 - padding: 0.5rem 0; 182 - border-bottom: 1px solid var(--border); 183 - } 184 - li:last-child { 185 - border-bottom: none; 186 - } 187 - li a { 188 - color: var(--accent); 189 - text-decoration: none; 190 - display: flex; 191 - align-items: center; 192 - gap: 0.75rem; 193 - } 194 - li a:hover { 195 - text-decoration: underline; 196 - } 197 - .folder { 198 - color: var(--folder); 199 - font-weight: 600; 200 - } 201 - .file { 202 - color: var(--accent); 203 - } 204 - .folder::before, 205 - .file::before, 206 - .parent::before { 207 - content: ""; 208 - display: inline-block; 209 - width: 1.25em; 210 - height: 1.25em; 211 - background-color: var(--icon); 212 - flex-shrink: 0; 213 - -webkit-mask-size: contain; 214 - mask-size: contain; 215 - -webkit-mask-repeat: no-repeat; 216 - mask-repeat: no-repeat; 217 - -webkit-mask-position: center; 218 - mask-position: center; 219 - } 220 - .folder::before { 221 - -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>'); 222 - mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>'); 223 - } 224 - .file::before { 225 - -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>'); 226 - mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>'); 227 - } 228 - .parent::before { 229 - -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>'); 230 - mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>'); 231 - } 232 - footer { 233 - margin-top: 2rem; 234 - padding-top: 1.5rem; 235 - border-top: 1px solid var(--border); 236 - text-align: center; 237 - font-size: 0.875rem; 238 - opacity: 0.7; 239 - color: var(--foreground); 240 - } 241 - footer a { 242 - color: var(--accent); 243 - text-decoration: none; 244 - display: inline; 245 - } 246 - footer a:hover { 247 - text-decoration: underline; 248 - } 249 - </style> 250 - </head> 251 - <body> 252 - <h1>Index of /${path}</h1> 253 - <ul> 254 - ${path ? '<li><a href="../" class="parent">../</a></li>' : ''} 255 - ${sortedEntries.map(e => 256 - `<li><a href="${e.name}${e.isDirectory ? '/' : ''}" class="${e.isDirectory ? 'folder' : 'file'}">${e.name}${e.isDirectory ? '/' : ''}</a></li>` 257 - ).join('\n ')} 258 - </ul> 259 - <footer> 260 - Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a> 261 - </footer> 262 - </body> 263 - </html>`; 264 - return html; 265 - } 266 - 267 - /** 268 - * Return a response indicating the site is being updated 269 - */ 270 - export function generateSiteUpdatingPage(): string { 271 - const html = `<!DOCTYPE html> 272 - <html> 273 - <head> 274 - <meta charset="utf-8"> 275 - <meta name="viewport" content="width=device-width, initial-scale=1"> 276 - <title>Site Updating</title> 277 - <style> 278 - @media (prefers-color-scheme: light) { 279 - :root { 280 - --background: oklch(0.90 0.012 35); 281 - --foreground: oklch(0.18 0.01 30); 282 - --primary: oklch(0.35 0.02 35); 283 - --accent: oklch(0.78 0.15 345); 284 - } 285 - } 286 - @media (prefers-color-scheme: dark) { 287 - :root { 288 - --background: oklch(0.23 0.015 285); 289 - --foreground: oklch(0.90 0.005 285); 290 - --primary: oklch(0.70 0.10 295); 291 - --accent: oklch(0.85 0.08 5); 292 - } 293 - } 294 - body { 295 - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 296 - display: flex; 297 - align-items: center; 298 - justify-content: center; 299 - min-height: 100vh; 300 - margin: 0; 301 - background: var(--background); 302 - color: var(--foreground); 303 - } 304 - .container { 305 - text-align: center; 306 - padding: 2rem; 307 - max-width: 500px; 308 - } 309 - h1 { 310 - font-size: 2.5rem; 311 - margin-bottom: 1rem; 312 - font-weight: 600; 313 - color: var(--primary); 314 - } 315 - p { 316 - font-size: 1.25rem; 317 - opacity: 0.8; 318 - margin-bottom: 2rem; 319 - color: var(--foreground); 320 - } 321 - .spinner { 322 - border: 4px solid var(--accent); 323 - border-radius: 50%; 324 - border-top: 4px solid var(--primary); 325 - width: 40px; 326 - height: 40px; 327 - animation: spin 1s linear infinite; 328 - margin: 0 auto; 329 - } 330 - @keyframes spin { 331 - 0% { transform: rotate(0deg); } 332 - 100% { transform: rotate(360deg); } 333 - } 334 - </style> 335 - <meta http-equiv="refresh" content="3"> 336 - </head> 337 - <body> 338 - <div class="container"> 339 - <h1>Site Updating</h1> 340 - <p>This site is undergoing an update right now. Check back in a moment...</p> 341 - <div class="spinner"></div> 342 - </div> 343 - </body> 344 - </html>`; 345 - 346 - return html; 347 - } 348 - 349 - /** 350 - * Create a Response for site updating 351 - */ 352 - export function siteUpdatingResponse(): Response { 353 - return new Response(generateSiteUpdatingPage(), { 354 - status: 503, 355 - headers: { 356 - 'Content-Type': 'text/html; charset=utf-8', 357 - 'Cache-Control': 'no-store, no-cache, must-revalidate', 358 - 'Retry-After': '3', 359 - }, 360 - }); 361 - } 362 - 1 + export { generate404Page, generateDirectoryListing, generateSiteUpdatingPage, siteUpdatingResponse } from '@wispplace/page-generators';
+9 -3
bun.lock
··· 57 57 "@wispplace/fs-utils": "workspace:*", 58 58 "@wispplace/lexicons": "workspace:*", 59 59 "@wispplace/observability": "workspace:*", 60 + "@wispplace/page-generators": "workspace:*", 60 61 "@wispplace/safe-fetch": "workspace:*", 61 62 "@wispplace/tiered-storage": "workspace:*", 62 63 "hono": "^4.10.4", ··· 162 163 "@wispplace/constants": "workspace:*", 163 164 "@wispplace/fs-utils": "workspace:*", 164 165 "@wispplace/lexicons": "workspace:*", 166 + "@wispplace/page-generators": "workspace:*", 165 167 "commander": "^14.0.2", 166 168 "hono": "^4.7.4", 167 169 "ignore": "^7.0.5", ··· 264 266 "elysia", 265 267 "hono", 266 268 ], 269 + }, 270 + "packages/@wispplace/page-generators": { 271 + "name": "@wispplace/page-generators", 272 + "version": "1.0.0", 267 273 }, 268 274 "packages/@wispplace/safe-fetch": { 269 275 "name": "@wispplace/safe-fetch", ··· 1087 1093 1088 1094 "@wispplace/observability": ["@wispplace/observability@workspace:packages/@wispplace/observability"], 1089 1095 1096 + "@wispplace/page-generators": ["@wispplace/page-generators@workspace:packages/@wispplace/page-generators"], 1097 + 1090 1098 "@wispplace/safe-fetch": ["@wispplace/safe-fetch@workspace:packages/@wispplace/safe-fetch"], 1091 1099 1092 1100 "@wispplace/tiered-storage": ["@wispplace/tiered-storage@workspace:packages/@wispplace/tiered-storage"], ··· 2029 2037 2030 2038 "wisp-hosting-service/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], 2031 2039 2032 - "wispctl/@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], 2040 + "wispctl/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], 2033 2041 2034 2042 "wispctl/@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], 2035 2043 ··· 2268 2276 "wisp-hosting-service/@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 2269 2277 2270 2278 "wisp-hosting-service/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], 2271 - 2272 - "wispctl/@types/bun/bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], 2273 2279 2274 2280 "wispctl/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 2275 2281
+62 -9
cli/README.md
··· 6 6 bun run index.ts --help 7 7 ``` 8 8 9 - Deploying a site 9 + ## Install (pre-built binary) 10 + 11 + ```bash 12 + # macOS (Apple Silicon) 13 + curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-darwin 14 + chmod +x wisp-cli-aarch64-darwin 15 + 16 + # macOS (Intel) 17 + curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-darwin 18 + chmod +x wisp-cli-x86_64-darwin 19 + 20 + # macOS (Universal) 21 + curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-darwin-universal 22 + chmod +x wisp-cli-darwin-universal 23 + 24 + # Linux (x86_64) 25 + curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux 26 + chmod +x wisp-cli-x86_64-linux 27 + 28 + # Linux (ARM64) 29 + curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux 30 + chmod +x wisp-cli-aarch64-linux 31 + ``` 32 + 33 + ## Deploy a site 34 + 10 35 ```bash 11 36 bun run index.ts deploy alice.bsky.social --path . --site my-blog 12 37 bun run index.ts alice.bsky.social --path . --site my-blog 13 38 ``` 14 39 15 - List domains for an account: 40 + ## Pull a site from PDS 41 + 42 + Download a site from the PDS to your local machine (uses OAuth authentication): 16 43 17 44 ```bash 18 - bun run index.ts list domains alice.bsky.social 45 + # Pull to a specific directory 46 + bun run index.ts pull alice.bsky.social --site my-blog --output ./my-blog 47 + 48 + # Pull to current directory 49 + bun run index.ts pull alice.bsky.social --site my-blog 19 50 ``` 20 51 21 - List sites for an account: 52 + ## Serve a site locally 53 + 54 + Run a local server that monitors the firehose for real-time updates (uses OAuth authentication): 22 55 23 56 ```bash 24 - bun run index.ts list sites alice.bsky.social 57 + # Serve on http://localhost:8080 (default) 58 + bun run index.ts serve alice.bsky.social --site my-blog 59 + 60 + # Serve on a custom port 61 + bun run index.ts serve alice.bsky.social --site my-blog --port 3000 62 + 63 + # Enable SPA mode (serve index.html for all routes) 64 + bun run index.ts serve alice.bsky.social --site my-blog --spa 65 + 66 + # Enable directory listing for paths without index files 67 + bun run index.ts serve alice.bsky.social --site my-blog --directory 25 68 ``` 26 69 27 - Use an alternate proxy service DID: 70 + ## List domains / sites 28 71 29 72 ```bash 30 - bun run index.ts list domains alice.bsky.social --service did:web:regents-macbook-air.west-major.ts.net 73 + bun run index.ts list domains alice.bsky.social 74 + bun run index.ts list sites alice.bsky.social 31 75 ``` 32 76 33 - Domain CRUD examples: 77 + ## Domain CRUD 34 78 35 79 ```bash 36 80 bun run index.ts domain claim alice.bsky.social --domain example.com ··· 41 85 bun run index.ts site delete alice.bsky.social --site mysite 42 86 ``` 43 87 44 - OAuth note: 88 + ## Options 89 + 90 + Use an alternate proxy service DID: 91 + 92 + ```bash 93 + bun run index.ts list domains alice.bsky.social --service did:web:regents-macbook-air.west-major.ts.net 94 + ``` 95 + 96 + ## OAuth note 97 + 45 98 - CLI requests `rpc:<nsid>?aud=*` scopes for Wisp XRPC methods. 46 99 - `--service did:...` controls proxy target (`atproto-proxy`), not scope audience (scoping audience couldnt work for me idk why).
+26 -49
cli/commands/serve.ts
··· 5 5 import type { Record as SettingsRecord } from '@wispplace/lexicons/types/place/wisp/settings'; 6 6 import { resolveDid, getPdsForDid } from '@wispplace/atproto-utils'; 7 7 import { existsSync, readFileSync, statSync, readdirSync } from 'fs'; 8 - import { join, extname } from 'path'; 8 + import { join } from 'path'; 9 + import { generate404Page, generateDirectoryListing } from '@wispplace/page-generators'; 9 10 import { lookup } from 'mime-types'; 10 11 import { pull } from './pull.ts'; 11 12 import { createSpinner, pc } from '../lib/progress.ts'; ··· 16 17 site: string; 17 18 path: string; 18 19 port: number; 20 + spa?: string | boolean; 21 + directoryListing?: boolean; 19 22 } 20 23 21 24 interface SiteState { ··· 25 28 siteDir: string; 26 29 settings: SettingsRecord | null; 27 30 redirectRules: RedirectRule[]; 31 + // CLI flag overrides (take precedence over settings record) 32 + spaOverride?: string | boolean; 33 + directoryListingOverride?: boolean; 28 34 } 29 35 30 36 async function fetchSettings(pdsEndpoint: string, did: string, rkey: string): Promise<SettingsRecord | null> { ··· 56 62 return settings?.indexFiles || ['index.html', 'index.htm']; 57 63 } 58 64 59 - function generateDirectoryListing(dirPath: string, urlPath: string): string { 65 + function buildDirectoryListing(dirPath: string, urlPath: string): string { 60 66 const entries = readdirSync(dirPath, { withFileTypes: true }); 61 - 62 - const items = entries 67 + const normalized = urlPath.replace(/^\//, '').replace(/\/$/, ''); 68 + return generateDirectoryListing(normalized, entries 63 69 .filter(e => !e.name.startsWith('.')) 64 - .sort((a, b) => { 65 - if (a.isDirectory() && !b.isDirectory()) return -1; 66 - if (!a.isDirectory() && b.isDirectory()) return 1; 67 - return a.name.localeCompare(b.name); 68 - }) 69 - .map(entry => { 70 - const isDir = entry.isDirectory(); 71 - const name = isDir ? `${entry.name}/` : entry.name; 72 - const href = urlPath === '/' ? `/${entry.name}` : `${urlPath}/${entry.name}`; 73 - return `<li><a href="${href}">${name}</a></li>`; 74 - }); 75 - 76 - const parentLink = urlPath !== '/' 77 - ? `<li><a href="${urlPath.split('/').slice(0, -1).join('/') || '/'}">..</a></li>` 78 - : ''; 79 - 80 - return `<!DOCTYPE html> 81 - <html> 82 - <head><title>Index of ${urlPath}</title> 83 - <style>body{font-family:system-ui;padding:2rem}ul{list-style:none;padding:0}li{padding:0.25rem 0}a{color:#0066cc}</style> 84 - </head> 85 - <body> 86 - <h1>Index of ${urlPath}</h1> 87 - <ul>${parentLink}${items.join('')}</ul> 88 - </body> 89 - </html>`; 90 - } 91 - 92 - function generate404Page(): string { 93 - return `<!DOCTYPE html> 94 - <html> 95 - <head><title>404 Not Found</title> 96 - <style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0} 97 - .container{text-align:center}h1{font-size:4rem;margin:0;color:#666}p{color:#999}</style> 98 - </head> 99 - <body> 100 - <div class="container"><h1>404</h1><p>Page not found</p></div> 101 - </body> 102 - </html>`; 70 + .map(e => ({ name: e.name, isDirectory: e.isDirectory() })) 71 + ); 103 72 } 104 73 105 74 function serveFile(filePath: string): Response { ··· 153 122 // Resolve file path 154 123 let filePath = join(state.siteDir, urlPath); 155 124 125 + // Resolve effective settings (CLI flags take precedence over settings record) 126 + const directoryListingEnabled = state.directoryListingOverride ?? state.settings?.directoryListing ?? false; 127 + const spaFile = state.spaOverride !== undefined 128 + ? (state.spaOverride === true ? 'index.html' : state.spaOverride || undefined) 129 + : state.settings?.spaMode; 130 + 156 131 // Check if it's a directory 157 132 if (existsSync(filePath) && statSync(filePath).isDirectory()) { 158 133 // Try index files ··· 165 140 } 166 141 167 142 // Directory listing if enabled 168 - if (state.settings?.directoryListing) { 169 - const html = generateDirectoryListing(filePath, urlPath); 143 + if (directoryListingEnabled) { 144 + const html = buildDirectoryListing(filePath, urlPath); 170 145 return new Response(html, { 171 146 headers: { 'Content-Type': 'text/html' } 172 147 }); ··· 192 167 } 193 168 } 194 169 195 - // SPA mode - serve index.html for all routes 196 - if (state.settings?.spaMode) { 197 - const spaPath = join(state.siteDir, state.settings.spaMode); 170 + // SPA mode - serve the SPA file for all unmatched routes 171 + if (spaFile) { 172 + const spaPath = join(state.siteDir, spaFile); 198 173 if (existsSync(spaPath)) { 199 174 return serveFile(spaPath); 200 175 } ··· 275 250 pdsEndpoint, 276 251 siteDir: outputPath, 277 252 settings, 278 - redirectRules 253 + redirectRules, 254 + spaOverride: options.spa, 255 + directoryListingOverride: options.directoryListing, 279 256 }; 280 257 281 258 // 5. Start HTTP server with Hono (works on both Bun and Node)
+5 -1
cli/index.ts
··· 261 261 .requiredOption('-s, --site <name>', 'Site name to serve') 262 262 .option('-p, --path <path>', 'Local directory to cache site', '.wisp-serve') 263 263 .option('-P, --port <port>', 'Port to serve on', '8080') 264 + .option('--spa [file]', 'Enable SPA mode (serve file for all unmatched routes, defaults to index.html)') 265 + .option('--directory-listing', 'Enable directory listing') 264 266 .action(withExit(async (handle: string, options) => { 265 267 await serve(handle, { 266 268 site: options.site, 267 269 path: options.path, 268 - port: parseInt(options.port, 10) 270 + port: parseInt(options.port, 10), 271 + spa: options.spa, 272 + directoryListing: options.directoryListing, 269 273 }); 270 274 })); 271 275
+1
cli/package.json
··· 31 31 "@types/mime-types": "^3.0.1", 32 32 "@types/node": "^22.0.0", 33 33 "@wispplace/atproto-utils": "workspace:*", 34 + "@wispplace/page-generators": "workspace:*", 34 35 "@wispplace/bun-firehose": "workspace:*", 35 36 "@wispplace/constants": "workspace:*", 36 37 "@wispplace/fs-utils": "workspace:*",
+14
packages/@wispplace/page-generators/package.json
··· 1 + { 2 + "name": "@wispplace/page-generators", 3 + "version": "1.0.0", 4 + "private": true, 5 + "type": "module", 6 + "main": "./src/index.ts", 7 + "types": "./src/index.ts", 8 + "exports": { 9 + ".": { 10 + "types": "./src/index.ts", 11 + "default": "./src/index.ts" 12 + } 13 + } 14 + }
+325
packages/@wispplace/page-generators/src/index.ts
··· 1 + /** 2 + * HTML page generation utilities shared between hosting-service and CLI 3 + */ 4 + 5 + export function generate404Page(): string { 6 + return `<!DOCTYPE html> 7 + <html> 8 + <head> 9 + <meta charset="utf-8"> 10 + <meta name="viewport" content="width=device-width, initial-scale=1"> 11 + <title>404 - Not Found</title> 12 + <style> 13 + @media (prefers-color-scheme: light) { 14 + :root { 15 + --background: oklch(0.90 0.012 35); 16 + --foreground: oklch(0.18 0.01 30); 17 + --border: oklch(0.75 0.015 30); 18 + --accent: oklch(0.78 0.15 345); 19 + } 20 + } 21 + @media (prefers-color-scheme: dark) { 22 + :root { 23 + --background: oklch(0.23 0.015 285); 24 + --foreground: oklch(0.90 0.005 285); 25 + --border: oklch(0.38 0.02 285); 26 + --accent: oklch(0.85 0.08 5); 27 + } 28 + } 29 + body { 30 + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; 31 + background: var(--background); 32 + color: var(--foreground); 33 + padding: 2rem; 34 + max-width: 800px; 35 + margin: 0 auto; 36 + display: flex; 37 + flex-direction: column; 38 + min-height: 100vh; 39 + justify-content: center; 40 + align-items: center; 41 + text-align: center; 42 + } 43 + h1 { 44 + font-size: 6rem; 45 + margin: 0; 46 + font-weight: 700; 47 + line-height: 1; 48 + } 49 + h2 { 50 + font-size: 1.5rem; 51 + margin: 1rem 0 2rem; 52 + font-weight: 400; 53 + opacity: 0.8; 54 + } 55 + p { 56 + font-size: 1rem; 57 + opacity: 0.7; 58 + margin-bottom: 2rem; 59 + } 60 + a { 61 + color: var(--accent); 62 + text-decoration: none; 63 + font-size: 1rem; 64 + } 65 + a:hover { 66 + text-decoration: underline; 67 + } 68 + footer { 69 + margin-top: 2rem; 70 + padding-top: 1.5rem; 71 + border-top: 1px solid var(--border); 72 + text-align: center; 73 + font-size: 0.875rem; 74 + opacity: 0.7; 75 + color: var(--foreground); 76 + } 77 + footer a { 78 + color: var(--accent); 79 + text-decoration: none; 80 + display: inline; 81 + } 82 + footer a:hover { 83 + text-decoration: underline; 84 + } 85 + </style> 86 + </head> 87 + <body> 88 + <div> 89 + <h1>404</h1> 90 + <h2>Page not found</h2> 91 + <p>The page you're looking for doesn't exist.</p> 92 + <a href="/">← Back to home</a> 93 + </div> 94 + <footer> 95 + Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a> 96 + </footer> 97 + </body> 98 + </html>`; 99 + } 100 + 101 + export function generateDirectoryListing(path: string, entries: Array<{ name: string; isDirectory: boolean }>): string { 102 + const sortedEntries = [...entries].sort((a, b) => { 103 + if (a.isDirectory && !b.isDirectory) return -1; 104 + if (!a.isDirectory && b.isDirectory) return 1; 105 + return a.name.localeCompare(b.name); 106 + }); 107 + 108 + return `<!DOCTYPE html> 109 + <html> 110 + <head> 111 + <meta charset="utf-8"> 112 + <meta name="viewport" content="width=device-width, initial-scale=1"> 113 + <title>Index of /${path}</title> 114 + <style> 115 + @media (prefers-color-scheme: light) { 116 + :root { 117 + --background: oklch(0.90 0.012 35); 118 + --foreground: oklch(0.18 0.01 30); 119 + --border: oklch(0.75 0.015 30); 120 + --accent: oklch(0.78 0.15 345); 121 + --folder: oklch(0.60 0.12 295); 122 + --icon: oklch(0.28 0.01 30); 123 + } 124 + } 125 + @media (prefers-color-scheme: dark) { 126 + :root { 127 + --background: oklch(0.23 0.015 285); 128 + --foreground: oklch(0.90 0.005 285); 129 + --border: oklch(0.38 0.02 285); 130 + --accent: oklch(0.85 0.08 5); 131 + --folder: oklch(0.70 0.10 295); 132 + --icon: oklch(0.85 0.005 285); 133 + } 134 + } 135 + body { 136 + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; 137 + background: var(--background); 138 + color: var(--foreground); 139 + padding: 2rem; 140 + max-width: 800px; 141 + margin: 0 auto; 142 + } 143 + h1 { 144 + font-size: 1.5rem; 145 + margin-bottom: 2rem; 146 + padding-bottom: 0.5rem; 147 + border-bottom: 1px solid var(--border); 148 + } 149 + ul { 150 + list-style: none; 151 + padding: 0; 152 + } 153 + li { 154 + padding: 0.5rem 0; 155 + border-bottom: 1px solid var(--border); 156 + } 157 + li:last-child { 158 + border-bottom: none; 159 + } 160 + li a { 161 + color: var(--accent); 162 + text-decoration: none; 163 + display: flex; 164 + align-items: center; 165 + gap: 0.75rem; 166 + } 167 + li a:hover { 168 + text-decoration: underline; 169 + } 170 + .folder { 171 + color: var(--folder); 172 + font-weight: 600; 173 + } 174 + .file { 175 + color: var(--accent); 176 + } 177 + .folder::before, 178 + .file::before, 179 + .parent::before { 180 + content: ""; 181 + display: inline-block; 182 + width: 1.25em; 183 + height: 1.25em; 184 + background-color: var(--icon); 185 + flex-shrink: 0; 186 + -webkit-mask-size: contain; 187 + mask-size: contain; 188 + -webkit-mask-repeat: no-repeat; 189 + mask-repeat: no-repeat; 190 + -webkit-mask-position: center; 191 + mask-position: center; 192 + } 193 + .folder::before { 194 + -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>'); 195 + mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>'); 196 + } 197 + .file::before { 198 + -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>'); 199 + mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>'); 200 + } 201 + .parent::before { 202 + -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>'); 203 + mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>'); 204 + } 205 + footer { 206 + margin-top: 2rem; 207 + padding-top: 1.5rem; 208 + border-top: 1px solid var(--border); 209 + text-align: center; 210 + font-size: 0.875rem; 211 + opacity: 0.7; 212 + color: var(--foreground); 213 + } 214 + footer a { 215 + color: var(--accent); 216 + text-decoration: none; 217 + display: inline; 218 + } 219 + footer a:hover { 220 + text-decoration: underline; 221 + } 222 + </style> 223 + </head> 224 + <body> 225 + <h1>Index of /${path}</h1> 226 + <ul> 227 + ${path ? '<li><a href="../" class="parent">../</a></li>' : ''} 228 + ${sortedEntries.map(e => 229 + `<li><a href="${e.name}${e.isDirectory ? '/' : ''}" class="${e.isDirectory ? 'folder' : 'file'}">${e.name}${e.isDirectory ? '/' : ''}</a></li>` 230 + ).join('\n ')} 231 + </ul> 232 + <footer> 233 + Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a> 234 + </footer> 235 + </body> 236 + </html>`; 237 + } 238 + 239 + export function generateSiteUpdatingPage(): string { 240 + return `<!DOCTYPE html> 241 + <html> 242 + <head> 243 + <meta charset="utf-8"> 244 + <meta name="viewport" content="width=device-width, initial-scale=1"> 245 + <title>Site Updating</title> 246 + <style> 247 + @media (prefers-color-scheme: light) { 248 + :root { 249 + --background: oklch(0.90 0.012 35); 250 + --foreground: oklch(0.18 0.01 30); 251 + --primary: oklch(0.35 0.02 35); 252 + --accent: oklch(0.78 0.15 345); 253 + } 254 + } 255 + @media (prefers-color-scheme: dark) { 256 + :root { 257 + --background: oklch(0.23 0.015 285); 258 + --foreground: oklch(0.90 0.005 285); 259 + --primary: oklch(0.70 0.10 295); 260 + --accent: oklch(0.85 0.08 5); 261 + } 262 + } 263 + body { 264 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 265 + display: flex; 266 + align-items: center; 267 + justify-content: center; 268 + min-height: 100vh; 269 + margin: 0; 270 + background: var(--background); 271 + color: var(--foreground); 272 + } 273 + .container { 274 + text-align: center; 275 + padding: 2rem; 276 + max-width: 500px; 277 + } 278 + h1 { 279 + font-size: 2.5rem; 280 + margin-bottom: 1rem; 281 + font-weight: 600; 282 + color: var(--primary); 283 + } 284 + p { 285 + font-size: 1.25rem; 286 + opacity: 0.8; 287 + margin-bottom: 2rem; 288 + color: var(--foreground); 289 + } 290 + .spinner { 291 + border: 4px solid var(--accent); 292 + border-radius: 50%; 293 + border-top: 4px solid var(--primary); 294 + width: 40px; 295 + height: 40px; 296 + animation: spin 1s linear infinite; 297 + margin: 0 auto; 298 + } 299 + @keyframes spin { 300 + 0% { transform: rotate(0deg); } 301 + 100% { transform: rotate(360deg); } 302 + } 303 + </style> 304 + <meta http-equiv="refresh" content="3"> 305 + </head> 306 + <body> 307 + <div class="container"> 308 + <h1>Site Updating</h1> 309 + <p>This site is undergoing an update right now. Check back in a moment...</p> 310 + <div class="spinner"></div> 311 + </div> 312 + </body> 313 + </html>`; 314 + } 315 + 316 + export function siteUpdatingResponse(): Response { 317 + return new Response(generateSiteUpdatingPage(), { 318 + status: 503, 319 + headers: { 320 + 'Content-Type': 'text/html; charset=utf-8', 321 + 'Cache-Control': 'no-store, no-cache, must-revalidate', 322 + 'Retry-After': '3', 323 + }, 324 + }); 325 + }
+9
packages/@wispplace/page-generators/tsconfig.json
··· 1 + { 2 + "extends": "../../../tsconfig.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*"], 8 + "exclude": ["node_modules", "dist"] 9 + }