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

Configure Feed

Select the types of activity you want to include in your feed.

docs redesign and desloppify

+1937 -1921
+77
apps/main-app/public/components/ui/table.tsx
··· 1 + import { cn } from '@public/lib/utils' 2 + import type * as React from 'react' 3 + 4 + function Table({ className, ...props }: React.ComponentProps<'table'>) { 5 + return ( 6 + <div data-slot="table-wrapper" className="relative w-full overflow-x-auto"> 7 + <table data-slot="table" className={cn('w-full caption-bottom text-sm', className)} {...props} /> 8 + </div> 9 + ) 10 + } 11 + 12 + function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) { 13 + return ( 14 + <thead data-slot="table-header" className={cn('[&_tr]:border-b [&_tr]:border-border/30', className)} {...props} /> 15 + ) 16 + } 17 + 18 + function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) { 19 + return <tbody data-slot="table-body" className={cn('[&_tr:last-child]:border-0', className)} {...props} /> 20 + } 21 + 22 + function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) { 23 + return ( 24 + <tfoot 25 + data-slot="table-footer" 26 + className={cn('border-t border-border/30 bg-muted/50 font-medium [&>tr]:last:border-b-0', className)} 27 + {...props} 28 + /> 29 + ) 30 + } 31 + 32 + function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { 33 + return ( 34 + <tr 35 + data-slot="table-row" 36 + className={cn( 37 + 'border-b border-border/20 transition-colors hover:bg-muted/10 data-[state=selected]:bg-muted/20', 38 + className, 39 + )} 40 + {...props} 41 + /> 42 + ) 43 + } 44 + 45 + function TableHead({ className, ...props }: React.ComponentProps<'th'>) { 46 + return ( 47 + <th 48 + data-slot="table-head" 49 + className={cn( 50 + 'h-9 px-3 text-left align-middle text-[10px] uppercase tracking-wider font-medium text-muted-foreground whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', 51 + className, 52 + )} 53 + {...props} 54 + /> 55 + ) 56 + } 57 + 58 + function TableCell({ className, ...props }: React.ComponentProps<'td'>) { 59 + return ( 60 + <td 61 + data-slot="table-cell" 62 + className={cn( 63 + 'px-3 py-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', 64 + className, 65 + )} 66 + {...props} 67 + /> 68 + ) 69 + } 70 + 71 + function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) { 72 + return ( 73 + <caption data-slot="table-caption" className={cn('mt-4 text-xs text-muted-foreground', className)} {...props} /> 74 + ) 75 + } 76 + 77 + export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
+258 -37
apps/main-app/public/editor/tabs/WebhooksTab.tsx
··· 4 4 import { Input } from '@public/components/ui/input' 5 5 import { Label } from '@public/components/ui/label' 6 6 import { SkeletonShimmer } from '@public/components/ui/skeleton' 7 - import { CheckCircle2, ChevronUp, ExternalLink, Loader2, Plus, RefreshCw, Trash2, Webhook } from 'lucide-react' 8 - import { type ChangeEvent, useCallback, useEffect, useRef, useState } from 'react' 7 + import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@public/components/ui/table' 8 + import { 9 + CheckCircle2, 10 + ChevronDown, 11 + ChevronsUpDown, 12 + ChevronUp, 13 + ExternalLink, 14 + Loader2, 15 + Plus, 16 + RefreshCw, 17 + Trash2, 18 + Webhook, 19 + X, 20 + } from 'lucide-react' 21 + import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' 9 22 import type { WebhookEventLog, WebhookRecord } from '../hooks/useWebhookData' 10 23 11 24 const APPS = [ ··· 229 242 setError(err instanceof Error ? err.message : 'Failed to create webhook') 230 243 } 231 244 } 245 + 246 + // ── Delivery-table state ────────────────────────────────────────────────── 247 + type DeliveryColumn = 'status' | 'eventKind' | 'eventCollection' | 'url' | 'deliveredAt' 248 + const [sortCol, setSortCol] = useState<DeliveryColumn>('deliveredAt') 249 + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') 250 + const [filterScope, setFilterScope] = useState('') 251 + const [filterStatus, setFilterStatus] = useState<'all' | 'ok' | 'failed'>('all') 252 + const [filterKind, setFilterKind] = useState<string>('all') 253 + 254 + const handleSortCol = (col: DeliveryColumn) => { 255 + if (sortCol === col) { 256 + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')) 257 + } else { 258 + setSortCol(col) 259 + setSortDir(col === 'deliveredAt' ? 'desc' : 'asc') 260 + } 261 + } 262 + 263 + const filteredLogs = useMemo(() => { 264 + let list = [...eventLogs] 265 + if (filterStatus !== 'all') list = list.filter((l) => l.status === filterStatus) 266 + if (filterKind !== 'all') list = list.filter((l) => l.eventKind === filterKind) 267 + if (filterScope.trim()) { 268 + const q = filterScope.trim().toLowerCase() 269 + list = list.filter((l) => l.eventCollection.toLowerCase().includes(q) || l.url.toLowerCase().includes(q)) 270 + } 271 + list.sort((a, b) => { 272 + let valA: string 273 + let valB: string 274 + switch (sortCol) { 275 + case 'status': 276 + valA = a.status 277 + valB = b.status 278 + break 279 + case 'eventKind': 280 + valA = a.eventKind 281 + valB = b.eventKind 282 + break 283 + case 'eventCollection': 284 + valA = a.eventCollection 285 + valB = b.eventCollection 286 + break 287 + case 'url': 288 + valA = a.url 289 + valB = b.url 290 + break 291 + default: 292 + valA = a.deliveredAt 293 + valB = b.deliveredAt 294 + break 295 + } 296 + const cmp = valA < valB ? -1 : valA > valB ? 1 : 0 297 + return sortDir === 'asc' ? cmp : -cmp 298 + }) 299 + return list 300 + }, [eventLogs, filterStatus, filterKind, filterScope, sortCol, sortDir]) 301 + 302 + const uniqueKinds = useMemo(() => Array.from(new Set(eventLogs.map((l) => l.eventKind))).sort(), [eventLogs]) 303 + 304 + const SortIcon = ({ col }: { col: DeliveryColumn }) => { 305 + if (sortCol !== col) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" /> 306 + return sortDir === 'asc' ? ( 307 + <ChevronUp className="inline w-3 h-3 ml-1" /> 308 + ) : ( 309 + <ChevronDown className="inline w-3 h-3 ml-1" /> 310 + ) 311 + } 312 + // ───────────────────────────────────────────────────────────────────────── 232 313 233 314 const Kbd = ({ children }: { children: React.ReactNode }) => ( 234 315 <kbd className="px-2 py-1 bg-muted/50 rounded border border-border/50">{children}</kbd> ··· 611 692 )} 612 693 </div> 613 694 614 - {/* Event Logs */} 615 - <div className="p-4 border-t border-border/30 space-y-2"> 616 - <div className="flex items-center justify-between mb-3"> 695 + {/* Event Logs — table */} 696 + <div className="p-4 border-t border-border/30 space-y-3"> 697 + {/* Section header */} 698 + <div className="flex items-center justify-between"> 617 699 <p className="text-xs uppercase tracking-wider text-muted-foreground">Recent Deliveries</p> 618 700 <Button 619 701 variant="outline" ··· 627 709 </Button> 628 710 </div> 629 711 712 + {/* Filter bar — only show when there's data */} 713 + {!eventLogsLoading && eventLogs.length > 0 && ( 714 + <div className="flex flex-wrap gap-2 items-center"> 715 + {/* Scope / URL search */} 716 + <div className="relative"> 717 + <Input 718 + value={filterScope} 719 + onChange={(e: ChangeEvent<HTMLInputElement>) => setFilterScope(e.target.value)} 720 + placeholder="Filter by collection or URL…" 721 + className="h-7 text-xs pr-7 w-72 font-mono" 722 + /> 723 + {filterScope && ( 724 + <button 725 + type="button" 726 + onClick={() => setFilterScope('')} 727 + className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" 728 + > 729 + <X className="w-3 h-3" /> 730 + </button> 731 + )} 732 + </div> 733 + 734 + {/* Status filter */} 735 + <div className="flex items-center gap-1 border border-border/30 rounded-sm overflow-hidden"> 736 + {(['all', 'ok', 'failed'] as const).map((s) => ( 737 + <button 738 + key={s} 739 + type="button" 740 + onClick={() => setFilterStatus(s)} 741 + className={`px-2.5 py-1 text-[10px] uppercase tracking-wider transition-colors ${ 742 + filterStatus === s 743 + ? s === 'failed' 744 + ? 'bg-destructive/20 text-destructive' 745 + : s === 'ok' 746 + ? 'bg-green-500/20 text-green-500' 747 + : 'bg-accent/20 text-foreground' 748 + : 'text-muted-foreground hover:text-foreground hover:bg-muted/20' 749 + }`} 750 + > 751 + {s} 752 + </button> 753 + ))} 754 + </div> 755 + 756 + {/* Event-kind filter */} 757 + {uniqueKinds.length > 0 && ( 758 + <div className="flex items-center gap-1 border border-border/30 rounded-sm overflow-hidden"> 759 + <button 760 + type="button" 761 + onClick={() => setFilterKind('all')} 762 + className={`px-2.5 py-1 text-[10px] uppercase tracking-wider transition-colors ${ 763 + filterKind === 'all' 764 + ? 'bg-accent/20 text-foreground' 765 + : 'text-muted-foreground hover:text-foreground hover:bg-muted/20' 766 + }`} 767 + > 768 + all 769 + </button> 770 + {uniqueKinds.map((k) => ( 771 + <button 772 + key={k} 773 + type="button" 774 + onClick={() => setFilterKind(k)} 775 + className={`px-2.5 py-1 text-[10px] uppercase tracking-wider capitalize transition-colors ${ 776 + filterKind === k 777 + ? 'bg-accent/20 text-foreground' 778 + : 'text-muted-foreground hover:text-foreground hover:bg-muted/20' 779 + }`} 780 + > 781 + {k} 782 + </button> 783 + ))} 784 + </div> 785 + )} 786 + 787 + {/* Active-filter count badge */} 788 + {filteredLogs.length !== eventLogs.length && ( 789 + <span className="text-[10px] text-muted-foreground"> 790 + {filteredLogs.length} / {eventLogs.length} 791 + </span> 792 + )} 793 + </div> 794 + )} 795 + 796 + {/* Table */} 630 797 {eventLogsLoading ? ( 631 798 <div className="space-y-1.5"> 632 799 {['a', 'b', 'c'].map((id) => ( 633 - <SkeletonShimmer key={id} className="h-10 w-full" /> 800 + <SkeletonShimmer key={id} className="h-9 w-full" /> 634 801 ))} 635 802 </div> 636 803 ) : eventLogs.length === 0 ? ( 637 804 <p className="text-xs text-muted-foreground py-4 text-center">No delivery events yet</p> 805 + ) : filteredLogs.length === 0 ? ( 806 + <p className="text-xs text-muted-foreground py-4 text-center">No deliveries match the current filters</p> 638 807 ) : ( 639 - <div className="space-y-1"> 640 - {eventLogs.map((log) => ( 641 - <div 642 - key={`${log.rkey}-${log.deliveredAt}`} 643 - className="flex items-center gap-3 p-2.5 border border-border/20 hover:bg-muted/10 transition-colors" 644 - > 645 - {/* Status indicator */} 646 - <div 647 - className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${ 648 - log.status === 'ok' ? 'bg-green-500' : 'bg-red-500' 649 - }`} 650 - /> 808 + <div className="border border-border/30 rounded-sm overflow-hidden"> 809 + <Table> 810 + <TableHeader> 811 + <TableRow className="hover:bg-transparent"> 812 + {/* Status */} 813 + <TableHead className="cursor-pointer select-none w-16" onClick={() => handleSortCol('status')}> 814 + Status <SortIcon col="status" /> 815 + </TableHead> 816 + {/* Event kind */} 817 + <TableHead className="cursor-pointer select-none w-24" onClick={() => handleSortCol('eventKind')}> 818 + Event <SortIcon col="eventKind" /> 819 + </TableHead> 820 + {/* Collection / scope */} 821 + <TableHead className="cursor-pointer select-none" onClick={() => handleSortCol('eventCollection')}> 822 + Collection <SortIcon col="eventCollection" /> 823 + </TableHead> 824 + {/* URL */} 825 + <TableHead 826 + className="cursor-pointer select-none hidden md:table-cell" 827 + onClick={() => handleSortCol('url')} 828 + > 829 + Endpoint <SortIcon col="url" /> 830 + </TableHead> 831 + {/* Time */} 832 + <TableHead 833 + className="cursor-pointer select-none w-24 text-right" 834 + onClick={() => handleSortCol('deliveredAt')} 835 + > 836 + Time <SortIcon col="deliveredAt" /> 837 + </TableHead> 838 + </TableRow> 839 + </TableHeader> 840 + <TableBody> 841 + {filteredLogs.map((log) => ( 842 + <TableRow key={`${log.rkey}-${log.deliveredAt}`}> 843 + {/* Status */} 844 + <TableCell> 845 + <Badge 846 + variant={log.status === 'ok' ? 'default' : 'destructive'} 847 + className="text-[10px] tabular-nums" 848 + > 849 + {log.status === 'ok' ? '200' : 'ERR'} 850 + </Badge> 851 + </TableCell> 852 + 853 + {/* Event kind */} 854 + <TableCell> 855 + <span className="text-xs capitalize font-medium">{log.eventKind}</span> 856 + </TableCell> 857 + 858 + {/* Collection */} 859 + <TableCell> 860 + <span 861 + className="text-xs font-mono text-muted-foreground truncate block max-w-[16rem]" 862 + title={log.eventCollection} 863 + > 864 + {log.eventCollection || '—'} 865 + </span> 866 + </TableCell> 867 + 868 + {/* URL */} 869 + <TableCell className="hidden md:table-cell"> 870 + <a 871 + href={log.url} 872 + target="_blank" 873 + rel="noopener noreferrer" 874 + className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1 max-w-[14rem] truncate transition-colors" 875 + title={log.url} 876 + > 877 + <span className="truncate">{log.url}</span> 878 + <ExternalLink className="w-2.5 h-2.5 flex-shrink-0" /> 879 + </a> 880 + </TableCell> 651 881 652 - {/* Event info */} 653 - <div className="flex-1 min-w-0"> 654 - <div className="flex items-center gap-2"> 655 - <Badge variant={log.status === 'ok' ? 'default' : 'destructive'} className="text-[10px]"> 656 - {log.status === 'ok' ? '200' : 'ERR'} 657 - </Badge> 658 - <span className="text-xs font-medium capitalize">{log.eventKind}</span> 659 - <span className="text-xs text-muted-foreground truncate">{log.eventCollection}</span> 660 - </div> 661 - <div className="flex items-center gap-2 mt-0.5"> 662 - <span className="text-[10px] text-muted-foreground truncate max-w-[12rem]">{log.url}</span> 663 - <span className="text-[10px] text-muted-foreground/50">•</span> 664 - <span className="text-[10px] text-muted-foreground whitespace-nowrap"> 665 - {formatTimeAgo(log.deliveredAt)} 666 - </span> 667 - </div> 668 - </div> 669 - </div> 670 - ))} 882 + {/* Time */} 883 + <TableCell className="text-right"> 884 + <span className="text-[10px] text-muted-foreground whitespace-nowrap"> 885 + {formatTimeAgo(log.deliveredAt)} 886 + </span> 887 + </TableCell> 888 + </TableRow> 889 + ))} 890 + </TableBody> 891 + </Table> 671 892 </div> 672 893 )} 673 894 </div>
+6 -1
docs/astro.config.mjs
··· 9 9 title: 'Wisp.place Docs', 10 10 components: { 11 11 SocialIcons: './src/components/SocialIcons.astro', 12 + PageFrame: './src/components/PageFrame.astro', 13 + Head: './src/components/Head.astro', 12 14 }, 13 15 sidebar: [ 14 16 { ··· 33 35 }, 34 36 { 35 37 label: 'Reference', 36 - autogenerate: { directory: 'reference' }, 38 + items: [ 39 + { label: 'XRPC API', slug: 'reference/xrpc-api' }, 40 + { label: 'Main App API', slug: 'reference/main-app-api' }, 41 + ], 37 42 }, 38 43 ], 39 44 customCss: ['./src/styles/custom.css'],
+7
docs/src/components/Head.astro
··· 1 + --- 2 + import Default from '@astrojs/starlight/components/Head.astro'; 3 + import { ViewTransitions } from 'astro:transitions'; 4 + --- 5 + 6 + <Default {...Astro.props} /> 7 + <ViewTransitions />
+139
docs/src/components/PageFrame.astro
··· 1 + --- 2 + import MobileMenuToggle from 'virtual:starlight/components/MobileMenuToggle'; 3 + 4 + const { hasSidebar } = Astro.locals.starlightRoute; 5 + --- 6 + 7 + <div class="page sl-flex"> 8 + <header class="header"><slot name="header" /></header> 9 + <div class="main-frame"> 10 + <div class="frame-content"> 11 + { 12 + hasSidebar && ( 13 + <nav class="sidebar print:hidden" aria-label={Astro.locals.t('sidebarNav.accessibleLabel')} transition:persist transition:name="sidebar"> 14 + <MobileMenuToggle /> 15 + <div id="starlight__sidebar" class="sidebar-pane"> 16 + <div class="sidebar-content sl-flex"> 17 + <slot name="sidebar" /> 18 + </div> 19 + </div> 20 + </nav> 21 + ) 22 + } 23 + <div class="content-col"><slot /></div> 24 + </div> 25 + </div> 26 + </div> 27 + 28 + <style> 29 + @layer starlight.core { 30 + .page { 31 + flex-direction: column; 32 + min-height: 100vh; 33 + } 34 + 35 + .header { 36 + z-index: var(--sl-z-index-navbar); 37 + position: fixed; 38 + inset-inline-start: 0; 39 + inset-block-start: 0; 40 + width: 100%; 41 + height: var(--sl-nav-height); 42 + border-bottom: 1px solid var(--sl-color-hairline-shade); 43 + padding: var(--sl-nav-pad-y) var(--sl-nav-pad-x); 44 + background-color: var(--sl-color-bg-nav); 45 + } 46 + 47 + /* Mobile: show hamburger button space */ 48 + :global([data-has-sidebar]) .header { 49 + padding-inline-end: calc( 50 + var(--sl-nav-gap) + var(--sl-nav-pad-x) + var(--sl-menu-button-size) 51 + ); 52 + } 53 + 54 + .main-frame { 55 + padding-top: calc(var(--sl-nav-height) + var(--sl-mobile-toc-height)); 56 + flex: 1; 57 + } 58 + 59 + .frame-content { 60 + display: flex; 61 + height: 100%; 62 + max-width: calc(var(--sl-content-width) + 2 * var(--sl-sidebar-width)); 63 + margin-inline: auto; 64 + width: 100%; 65 + } 66 + 67 + /* ── Mobile: sidebar is a full-screen overlay ── */ 68 + .sidebar-pane { 69 + visibility: var(--sl-sidebar-visibility, hidden); 70 + position: fixed; 71 + z-index: var(--sl-z-index-menu); 72 + inset-block: var(--sl-nav-height) 0; 73 + inset-inline-start: 0; 74 + width: 100%; 75 + background-color: var(--sl-color-black); 76 + overflow-y: auto; 77 + } 78 + 79 + :global([aria-expanded='true']) ~ .sidebar-pane { 80 + --sl-sidebar-visibility: visible; 81 + } 82 + 83 + .sidebar-content { 84 + height: 100%; 85 + min-height: max-content; 86 + padding: 1rem var(--sl-sidebar-pad-x) 0; 87 + flex-direction: column; 88 + gap: 1rem; 89 + } 90 + 91 + .content-col { 92 + flex: 1; 93 + min-width: 0; 94 + } 95 + 96 + /* ── Desktop: sidebar is in-flow and sticky ── */ 97 + @media (min-width: 50rem) { 98 + :global([data-has-sidebar]) .header { 99 + padding-inline-end: var(--sl-nav-pad-x); 100 + } 101 + 102 + .sidebar { 103 + width: var(--sl-sidebar-width); 104 + flex-shrink: 0; 105 + position: sticky; 106 + top: var(--sl-nav-height); 107 + height: calc(100vh - var(--sl-nav-height)); 108 + overflow-y: auto; 109 + } 110 + 111 + .sidebar-pane { 112 + --sl-sidebar-visibility: visible; 113 + position: relative; 114 + inset: unset; 115 + width: 100%; 116 + height: 100%; 117 + background-color: var(--sl-color-bg-sidebar); 118 + overflow-y: auto; 119 + z-index: 1; 120 + } 121 + } 122 + } 123 + </style> 124 + 125 + <script> 126 + // Since the sidebar is persisted across view transitions, Astro won't 127 + // update aria-current automatically. Update it manually after each swap. 128 + document.addEventListener('astro:after-swap', () => { 129 + const currentPath = window.location.pathname; 130 + document.querySelectorAll('#starlight__sidebar a').forEach((link) => { 131 + const linkPath = new URL(link.href, window.location.origin).pathname; 132 + if (linkPath === currentPath) { 133 + link.setAttribute('aria-current', 'page'); 134 + } else { 135 + link.removeAttribute('aria-current'); 136 + } 137 + }); 138 + }); 139 + </script>
+65 -136
docs/src/content/docs/architecture.md
··· 1 1 --- 2 - title: Architecture Guide 2 + title: Architecture 3 3 description: How the hosting service, firehose service, and tiered storage work together 4 4 --- 5 5 6 - Wisp.place's serving infrastructure is split into two microservices: the **firehose service** (write path) and the **hosting service** (read path). They communicate through S3-compatible storage and Redis pub/sub. 6 + Wisp.place splits into two microservices: the **firehose service** (write path) and the **hosting service** (read path). They communicate through S3-compatible storage and Redis pub/sub. 7 7 8 - ## Service Overview 9 - 10 - ### Firehose Service 11 - 12 - The firehose service watches the AT Protocol firehose (Jetstream WebSocket) for `place.wisp.fs` and `place.wisp.settings` record changes. When a site is created, updated, or deleted, it: 13 - 14 - 1. Downloads all blobs from the user's PDS 15 - 2. Decompresses gzipped content 16 - 3. Rewrites HTML for subdirectory serving (absolute paths become relative) 17 - 4. Writes the processed files to S3 (or disk) 18 - 5. Publishes a cache invalidation event to Redis 8 + ## Firehose Service 19 9 20 - The firehose service is **write-only** — it never serves requests to end users. 10 + The firehose service watches the AT Protocol Jetstream for `place.wisp.fs` and `place.wisp.settings` record changes. When a site is created or updated, it downloads all blobs from the user's PDS, decompresses gzipped content, rewrites HTML for subdirectory serving, writes processed files to S3 (or disk), then publishes a cache invalidation event to Redis. 21 11 22 - **Key configuration:** 12 + It's write-only — it never serves requests to end users. 23 13 24 14 ```bash 25 - # Firehose connection 26 - FIREHOSE_URL="wss://jetstream2.us-east.bsky.network/subscribe" 27 - 28 - # S3 storage (recommended for production) 15 + FIREHOSE_SERVICE="wss://bsky.network" 16 + FIREHOSE_MAX_CONCURRENCY=5 29 17 S3_BUCKET="wisp-sites" 30 - S3_REGION="auto" 18 + S3_REGION="us-east-1" 31 19 S3_ENDPOINT="https://your-account.r2.cloudflarestorage.com" 32 - S3_ACCESS_KEY_ID="..." 33 - S3_SECRET_ACCESS_KEY="..." 34 - 35 - # Redis for cache invalidation 20 + S3_FORCE_PATH_STYLE="false" 21 + S3_PREFIX="sites/" 22 + AWS_ACCESS_KEY_ID="..." 23 + AWS_SECRET_ACCESS_KEY="..." 36 24 REDIS_URL="redis://localhost:6379" 37 - 38 - # Concurrency control 39 - FIREHOSE_CONCURRENCY=5 # Max parallel event processing 40 25 ``` 41 26 42 - **Backfill mode:** Start with `--backfill` to do a one-time bulk sync of all existing sites from the database into the cache. 27 + Start with `--backfill` to do a one-time bulk sync of all existing sites into cache. 43 28 44 - ### Hosting Service 29 + ## Hosting Service 45 30 46 - The hosting service is a **read-only** CDN built with Node.js and Hono. It serves static files from a three-tier cache and handles routing for custom domains, wisp subdomains, and direct URLs. 31 + The hosting service is a read-only CDN built with Hono. It resolves sites from the request hostname or path, looks up files in tiered storage (hot → warm → cold), fetches directly from the user's PDS on a cache miss, applies HTML path rewriting and `_redirects` rules, and serves the file. 47 32 48 - On each request, the hosting service: 49 - 50 - 1. Resolves the site from the request hostname/path 51 - 2. Looks up the file in tiered storage (hot → warm → cold) 52 - 3. On a cache miss, fetches from the PDS on-demand and populates the cache 53 - 4. Applies HTML path rewriting if serving from a subdirectory 54 - 5. Processes `_redirects` rules 55 - 6. Serves the file with appropriate headers 56 - 57 - The hosting service subscribes to Redis pub/sub for cache invalidation messages from the firehose service. When it receives an invalidation, it evicts the affected entries from its hot and warm tiers so the next request fetches fresh content. 33 + It subscribes to Redis pub/sub for invalidation events from the firehose service. On invalidation, it evicts affected entries from hot and warm tiers so the next request fetches fresh content. 58 34 59 35 ## Tiered Storage 60 36 61 - The `@wispplace/tiered-storage` package implements a three-tier cascading cache. Data flows **down** on writes and is looked up **upward** on reads. 37 + `@wispplace/tiered-storage` implements a three-tier cascading cache: 62 38 63 39 ``` 64 - Read path: Hot (memory) → Warm (disk) → Cold (S3/disk) 65 - Write path: Hot ← Warm ← Cold (writes cascade down through all tiers) 40 + Read: Hot (memory) → Warm (disk) → Cold (S3/disk) 41 + Write: Hot ← Warm ← Cold 66 42 ``` 67 43 68 - ### Hot Tier (Memory) 69 - 70 - - **Implementation:** In-memory LRU cache 71 - - **Eviction:** Size-based (bytes) and count-based (max items) 72 - - **Use case:** Frequently accessed files (index.html, CSS, JS) 73 - - **Lost on restart** — repopulated from warm/cold tiers on access 44 + The **hot tier** is an in-memory LRU cache. Fast, small, and lost on restart — repopulated from warm/cold on access. 74 45 75 46 ```bash 76 - HOT_CACHE_SIZE=104857600 # 100 MB (default) 77 - HOT_CACHE_COUNT=500 # Max items 47 + HOT_CACHE_SIZE=104857600 # 100 MB 48 + HOT_CACHE_COUNT=500 78 49 ``` 79 50 80 - ### Warm Tier (Disk) 81 - 82 - - **Implementation:** Filesystem with human-readable paths 83 - - **Eviction:** Configurable — `lru` (default), `fifo`, or `size` 84 - - **Structure:** `cache/sites/{did}/{sitename}/path/to/file` 85 - - **Survives restarts** — provides fast local reads without network calls 51 + The **warm tier** is a disk cache at `cache/sites/{did}/{sitename}/path`. It survives restarts and requires no network. 86 52 87 53 ```bash 88 - WARM_CACHE_SIZE=10737418240 # 10 GB (default) 89 - WARM_EVICTION_POLICY=lru # lru, fifo, or size 54 + WARM_CACHE_SIZE=10737418240 # 10 GB 55 + WARM_EVICTION_POLICY=lru # lru, fifo, or size 90 56 CACHE_DIR=./cache/sites 91 57 ``` 92 58 93 - The warm tier is optional when S3 is configured. Without S3, disk acts as the cold (source of truth) tier. 94 - 95 - ### Cold Tier (S3 or Disk) 96 - 97 - - **With S3:** The firehose service writes here; the hosting service reads (read-only wrapper) 98 - - **Without S3:** A disk-based tier serves as both warm and cold 99 - - **Compatible with:** Cloudflare R2, MinIO, AWS S3, or any S3-compatible endpoint 59 + The **cold tier** is S3 (or disk if S3 isn't configured). The firehose writes here; the hosting service reads. Without S3, disk serves as both warm and cold. 100 60 101 61 ```bash 102 62 S3_BUCKET="wisp-sites" 103 - S3_REGION="auto" 63 + S3_REGION="us-east-1" 104 64 S3_ENDPOINT="https://your-account.r2.cloudflarestorage.com" 105 - S3_ACCESS_KEY_ID="..." 106 - S3_SECRET_ACCESS_KEY="..." 107 - S3_METADATA_BUCKET="wisp-metadata" # Optional, recommended for production 65 + S3_FORCE_PATH_STYLE="false" 66 + S3_PREFIX="sites/" 67 + AWS_ACCESS_KEY_ID="..." 68 + AWS_SECRET_ACCESS_KEY="..." 108 69 ``` 109 70 110 - ### Tier Placement Rules 111 - 112 - Not all files are placed on every tier. The hosting service uses placement rules to keep the hot tier efficient: 113 - 114 - | File Pattern | Tiers | Rationale | 115 - |---|---|---| 116 - | `index.html`, `*.css`, `*.js` | Hot, Warm, Cold | Critical for page loads | 117 - | Rewritten HTML (`.rewritten/`) | Hot, Warm, Cold | Pre-processed for fast serving | 118 - | Images, fonts, media (`*.jpg`, `*.woff2`, etc.) | Warm, Cold | Already compressed, large — skip memory | 119 - | Everything else | Warm, Cold | Default placement | 120 - 121 - ### Promotion and Bootstrap 122 - 123 - When a file is found in a lower tier but not a higher one, it's **eagerly promoted** upward. For example, a cache miss on hot that hits warm will copy the file into hot for future requests. 124 - 125 - On startup, the hosting service can **bootstrap** tiers: 126 - - Hot bootstraps from warm by loading the most-accessed items 127 - - Warm bootstraps from cold by loading recently written items 71 + Not everything goes on every tier. HTML, CSS, and JS go hot/warm/cold since they're critical for page loads. Large files like images and fonts skip hot — they'd just eat memory. When a file is found in a lower tier but not a higher one, it's promoted upward so the next request is faster. 128 72 129 73 ## Cache Invalidation 130 74 131 - The firehose service and hosting service communicate through Redis pub/sub: 132 - 133 75 ``` 134 - Firehose Service Hosting Service 135 - │ │ 136 - │ ── Redis pub/sub ──────────────→ │ 137 - │ (wisp:revalidate) │ 138 - │ │ 139 - │ Site updated/deleted: │ Receives invalidation: 140 - │ 1. Write new files to S3 │ 1. Evict from hot tier 141 - │ 2. Publish invalidation │ 2. Evict from warm tier 142 - │ │ 3. Next request fetches fresh 76 + Firehose Hosting 77 + │ │ 78 + │ ── Redis pub/sub ────────────→ │ 79 + │ (wisp:revalidate) │ 80 + │ │ 81 + │ Site updated: │ Receives invalidation: 82 + │ 1. Write new files to S3 │ 1. Evict from hot tier 83 + │ 2. Publish invalidation │ 2. Evict from warm tier 84 + │ │ 3. Next request fetches fresh 143 85 ``` 144 86 145 - If Redis is not configured, the hosting service still works — it just won't receive real-time invalidation and will rely on TTL-based expiry (default 14 days) and on-demand fetching. 87 + Without Redis the hosting service still works — it falls back to TTL-based expiry (14 days default) and on-demand fetching. 146 88 147 - ## On-Demand Cache Population 89 + ## Cache Misses 148 90 149 - When the hosting service receives a request for a site that isn't in any cache tier, it fetches directly from the user's PDS: 91 + The hosting service handles cache misses in two ways depending on whether it knows about the site. 150 92 151 - 1. Resolves the user's DID to their PDS endpoint 152 - 2. Downloads the `place.wisp.fs` record 153 - 3. Fetches the requested blob 154 - 4. Decompresses and processes the file 155 - 5. Stores it in the appropriate tiers based on placement rules 156 - 6. Serves the response 93 + If a site **is in the database** but its files are missing from all storage tiers, the request returns 503 and a revalidation job is enqueued to Redis for the firehose service to re-sync from the PDS. No direct PDS fetch happens here. 157 94 158 - This means the hosting service works even without the firehose service running — it just won't have pre-populated caches. 95 + If a site **is not in the database at all**, the hosting service fetches it directly from the PDS: it resolves the DID, downloads the `place.wisp.fs` record, fetches all blobs, writes them to hot and warm tiers, and then enqueues a revalidation job so the firehose backfills S3. 159 96 160 97 ## Deployment Scenarios 161 98 162 - ### Minimal (Disk Only) 163 - 164 - No S3 or Redis required. The hosting service uses disk as both warm and cold tier. Best for small deployments or development. 99 + **Disk only** — No S3 or Redis. The hosting service uses disk as both warm and cold. Good for small deployments and development. 165 100 166 101 ```bash 167 - # Hosting service only 168 102 CACHE_DIR=./cache/sites 169 103 HOT_CACHE_SIZE=104857600 170 104 ``` 171 105 172 - ### Production (S3 + Redis) 173 - 174 - The firehose service pre-populates S3 and notifies the hosting service of changes via Redis. Multiple hosting service instances can share the same S3 backend. 106 + **S3 + Redis** — The firehose pre-populates S3 and notifies the hosting service of changes. Multiple hosting instances can share the same S3 backend. 175 107 176 108 ```bash 177 - # Both services 178 109 S3_BUCKET=wisp-sites 179 110 S3_ENDPOINT=https://account.r2.cloudflarestorage.com 111 + AWS_ACCESS_KEY_ID=... 112 + AWS_SECRET_ACCESS_KEY=... 180 113 REDIS_URL=redis://localhost:6379 181 - 182 - # Hosting service 183 114 HOT_CACHE_SIZE=104857600 184 115 WARM_CACHE_SIZE=10737418240 185 116 ``` 186 117 187 - ### Scaled (Multiple Hosting Instances) 188 - 189 - Run multiple hosting service instances behind a load balancer. Each has its own hot and warm tiers, but they share the S3 cold tier and receive the same Redis invalidation events. 118 + **Scaled** — Run multiple hosting instances behind a load balancer. Each has its own hot and warm tiers but shares S3 and Redis invalidation. 190 119 191 120 ``` 192 - Load Balancer 193 - / | \ 194 - Hosting-1 Hosting-2 Hosting-3 195 - (hot+warm) (hot+warm) (hot+warm) 196 - \ | / 197 - S3 (cold tier) 198 - | 199 - Firehose Service 121 + Load Balancer 122 + / | \ 123 + Hosting-1 Hosting-2 Hosting-3 124 + (hot+warm) (hot+warm) (hot+warm) 125 + \ | / 126 + S3 (cold tier) 127 + | 128 + Firehose Service 200 129 ``` 201 130 202 131 ## Observability 203 132 204 - Both services expose internal observability endpoints: 133 + Both services expose internal endpoints: 205 134 206 - - `/__internal__/observability/logs` — Recent log entries 207 - - `/__internal__/observability/errors` — Error log entries 208 - - `/__internal__/observability/metrics` — Prometheus-format metrics 209 - - `/__internal__/observability/cache` — Cache tier statistics (hosting service only) 135 + - `/__internal__/observability/logs` 136 + - `/__internal__/observability/errors` 137 + - `/__internal__/observability/metrics` 138 + - `/__internal__/observability/cache` (hosting service only) 210 139 211 - See [Monitoring & Metrics](/monitoring) for Grafana integration details. 140 + See [Monitoring & Metrics](/monitoring).
+148 -197
docs/src/content/docs/cli.md
··· 1 1 --- 2 - title: Wisp CLI v1.0.0 2 + title: Wisp CLI 3 3 description: Command-line tool for deploying static sites to the AT Protocol 4 4 --- 5 5 6 - **Deploy static sites to the AT Protocol** 6 + `wispctl` deploys static sites to your AT Protocol account from the terminal. Supports incremental updates, OAuth and app password auth, and a local dev server with live firehose updates. 7 7 8 - The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account. Host your sites on wisp.place with full ownership and control, backed by the decentralized AT Protocol. 8 + ## Installation 9 9 10 - ## Features 10 + ```bash 11 + npm install -g wispctl 12 + ``` 11 13 12 - - **Deploy**: Push static sites directly from your terminal 13 - - **Pull**: Download sites from the PDS for development or backup 14 - - **Serve**: Run a local server with real-time firehose updates 15 - - **Authenticate** with app password or OAuth 16 - - **Incremental updates**: Only upload changed files 14 + Then use `wispctl` anywhere: 15 + 16 + ```bash 17 + wispctl deploy your-handle.bsky.social --path ./dist --site my-site 18 + ``` 17 19 18 - ## Downloads 20 + ## Quick Deploy 21 + 22 + No install needed — use `npm create wisp` to deploy directly: 23 + 24 + ```bash 25 + npm create wisp your-handle.bsky.social --path ./dist --site my-site 26 + ``` 27 + 28 + Or with `npx`: 29 + 30 + ```bash 31 + npx wispctl deploy your-handle.bsky.social --path ./dist --site my-site 32 + ``` 33 + 34 + ## Deploying a Site 35 + 36 + ```bash 37 + wispctl deploy your-handle.bsky.social --path ./dist --site my-site 38 + ``` 39 + 40 + Your site will be at `https://sites.wisp.place/your-handle/my-site`. 41 + 42 + The CLI tracks files by content hash (CID), so subsequent deploys only upload what actually changed. First deploy uploads everything; after that, deploys complete in seconds when only a few files differ. 19 43 20 - <div class="downloads"> 44 + ## Authentication 21 45 22 - <h2>Download v1.0.0</h2> 46 + OAuth is the default — it opens your browser and saves a session to `/tmp/wisp-oauth-session.json`. For CI/CD or headless environments, use an app password instead: 23 47 24 - <a href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-darwin" class="download-link" download=""> 48 + ```bash 49 + wispctl deploy your-handle.bsky.social \ 50 + --path ./dist \ 51 + --site my-site \ 52 + --password YOUR_APP_PASSWORD 53 + ``` 25 54 26 - <span class="platform">macOS (Apple Silicon):</span> wisp-cli-aarch64-darwin 55 + Generate app passwords from your AT Protocol account settings. Don't use your main password. 27 56 28 - </a> 57 + ## Domain Management 29 58 30 - <a href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-darwin" class="download-link" download=""> 59 + ```bash 60 + # Claim a wisp.place subdomain 61 + wispctl domain claim-subdomain your-handle.bsky.social --subdomain alice 31 62 32 - <span class="platform">macOS (Intel):</span> wisp-cli-x86_64-darwin 63 + # Claim a custom domain 64 + wispctl domain claim your-handle.bsky.social --domain example.com 33 65 34 - </a> 66 + # Check domain status 67 + wispctl domain status your-handle.bsky.social --domain example.com 35 68 36 - <a href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux" class="download-link" download=""> 69 + # Attach a site to a domain 70 + wispctl domain add-site your-handle.bsky.social --domain example.com --site mysite 37 71 38 - <span class="platform">Linux (ARM64):</span> wisp-cli-aarch64-linux 72 + # Delete a domain or site 73 + wispctl domain delete your-handle.bsky.social --domain example.com 74 + wispctl site delete your-handle.bsky.social --site mysite 75 + ``` 39 76 40 - </a> 77 + ```bash 78 + wispctl list domains your-handle.bsky.social 79 + wispctl list sites your-handle.bsky.social 80 + ``` 41 81 42 - <a href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux" class="download-link" download=""> 82 + ## Pulling a Site 43 83 44 - <span class="platform">Linux (x86_64):</span> wisp-cli-x86_64-linux 84 + Download a site from the PDS to your local machine: 45 85 46 - </a> 86 + ```bash 87 + wispctl pull your-handle.bsky.social --site my-site --path ./my-site 88 + ``` 47 89 48 - <h3 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">SHA-256 Checksums</h3> 90 + ## Local Dev Server 49 91 50 - <pre style="font-size: 0.75rem; padding: 1rem;" class="language-bash" tabindex="0"><code class="language-bash"> 51 - 06544b3a3e27a4b8d7b3a46a39fb7205cf90b3061e19fe533b090facd604f375 wisp-cli-aarch64-darwin 52 - 9ec523e3ceef927b37adc52d449dcd9e13ea84fa49b0b77f0d5932c94cfe262e wisp-cli-x86_64-darwin 53 - 42a262668e13dce36173a4096cdc2b22358b805cf192335f84534c7f695d395b wisp-cli-aarch64-linux 54 - 589ee59f3959ddfbc12fea38d2bcb91701f1362f560ae6fd506bebea3150e2cc wisp-cli-x86_64-linux 55 - </code></pre> 92 + Serve a site locally with real-time updates from the firehose: 56 93 57 - </div> 94 + ```bash 95 + wispctl serve your-handle.bsky.social --site my-site 96 + wispctl serve your-handle.bsky.social --site my-site --port 3000 97 + wispctl serve your-handle.bsky.social --site my-site --spa # serve index.html for all routes 98 + wispctl serve your-handle.bsky.social --site my-site --directory # directory listing 99 + ``` 58 100 59 - ## CI/CD Integration 101 + ## CI/CD 60 102 61 103 Deploy automatically on every push using Tangled Spindle: 62 104 ··· 70 112 71 113 dependencies: 72 114 nixpkgs: 73 - - nodejs 74 115 - coreutils 75 116 - curl 117 + - glibc 76 118 github:NixOS/nixpkgs/nixpkgs-unstable: 77 119 - bun 78 120 ··· 85 127 - name: build site 86 128 command: | 87 129 export PATH="$HOME/.nix-profile/bin:$PATH" 88 - 89 - # you may need to regenerate the lockfile due to nixery being weird 90 - # rm package-lock.json bun.lock 91 130 bun install 92 - 93 131 bun run build 94 132 95 133 - name: deploy to wisp 96 134 command: | 97 - # Download Wisp CLI 98 - curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 99 - chmod +x wisp-cli 100 - 101 - # Deploy to Wisp 102 - ./wisp-cli \ 135 + curl -fsSL https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wispctl 136 + chmod +x wispctl 137 + ./wispctl deploy \ 103 138 "$WISP_HANDLE" \ 104 139 --path "$SITE_PATH" \ 105 140 --site "$SITE_NAME" \ 106 141 --password "$WISP_APP_PASSWORD" 107 142 ``` 108 143 109 - **Note:** Set `WISP_APP_PASSWORD` as a secret in your Tangled Spindle repository settings. Generate an app password from your AT Protocol account settings. 110 - 111 - ## Basic Usage 112 - 113 - ### Deploy a Site 114 - 115 - ```bash 116 - # Download and make executable 117 - curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-darwin 118 - chmod +x wisp-cli-aarch64-darwin 144 + Set `WISP_APP_PASSWORD` as a secret in your Tangled Spindle repository settings. 119 145 120 - # Deploy your site 121 - ./wisp-cli-aarch64-darwin deploy your-handle.bsky.social \ 122 - --path ./dist \ 123 - --site my-site 124 - ``` 146 + ## File Processing 125 147 126 - Your site will be available at: `https://sites.wisp.place/your-handle/my-site` 148 + Files are gzip-compressed at level 9 and uploaded as `application/octet-stream` blobs with the original MIME type stored in the manifest. They may also be base64-encoded to bypass content sniffing on legacy reference PDS. The hosting service handles decompression transparently. 127 149 128 - ### Domain Management 150 + Common build artifacts like `.git`, `node_modules`, and `.env` are excluded automatically. Customize this with a [`.wispignore` file](/file-filtering). 129 151 130 - ```bash 131 - # Claim a custom domain 132 - ./wisp-cli domain claim your-handle.bsky.social --domain example.com 152 + ## Limits 133 153 134 - # Claim a subdomain 135 - ./wisp-cli domain claim-subdomain your-handle.bsky.social --subdomain alice 154 + - Max file size: 100 MB (after compression) 155 + - Max total size: 300 MB per site 156 + - Max files: 1,000 per site 157 + - Site name: alphanumeric, hyphens, underscores (AT Protocol rkey format) 136 158 137 - # Check domain status 138 - ./wisp-cli domain status your-handle.bsky.social --domain example.com 159 + ## Command Reference 139 160 140 - # Attach a site to a domain 141 - ./wisp-cli domain add-site your-handle.bsky.social --domain example.com --site mysite 161 + ### deploy 142 162 143 - # Delete a domain or site 144 - ./wisp-cli domain delete your-handle.bsky.social --domain example.com 145 - ./wisp-cli site delete your-handle.bsky.social --site mysite 146 163 ``` 164 + wispctl deploy [OPTIONS] <INPUT> 147 165 148 - ### List Domains & Sites 166 + Arguments: 167 + <INPUT> Handle (e.g., alice.bsky.social), DID, or PDS URL 149 168 150 - ```bash 151 - ./wisp-cli list domains your-handle.bsky.social 152 - ./wisp-cli list sites your-handle.bsky.social 169 + Options: 170 + -p, --path <PATH> Path to site directory [default: .] 171 + -s, --site <SITE> Site name (defaults to directory name) 172 + --store <STORE> OAuth session file [default: /tmp/wisp-oauth-session.json] 173 + --password <PASSWORD> App password 153 174 ``` 154 175 155 - ### Options 156 - 157 - Use an alternate proxy service DID: 176 + ### pull 158 177 159 - ```bash 160 - ./wisp-cli list domains your-handle.bsky.social --service did:web:example.com 161 178 ``` 162 - 163 - ### Pull a Site from PDS 164 - 165 - Download a site from the PDS to your local machine: 179 + wispctl pull [OPTIONS] --site <SITE> <INPUT> 166 180 167 - ```bash 168 - # Pull a site to a specific directory 169 - wisp-cli pull your-handle.bsky.social \ 170 - --site my-site \ 171 - --path ./my-site 172 - 173 - # Pull to current directory 174 - wisp-cli pull your-handle.bsky.social \ 175 - --site my-site 176 - ``` 177 - 178 - ### Serve a Site Locally with Real-Time Updates 179 - 180 - Run a local server that monitors the firehose for real-time updates: 181 - 182 - ```bash 183 - # Serve on http://localhost:8080 (default) 184 - wisp-cli serve your-handle.bsky.social \ 185 - --site my-site 186 - 187 - # Serve on a custom port 188 - wisp-cli serve your-handle.bsky.social \ 189 - --site my-site \ 190 - --port 3000 191 - 192 - # Enable SPA mode (serve index.html for all routes) 193 - wisp-cli serve your-handle.bsky.social \ 194 - --site my-site \ 195 - --spa 181 + Arguments: 182 + <INPUT> Handle or DID 196 183 197 - # Enable directory listing for paths without index files 198 - wisp-cli serve your-handle.bsky.social \ 199 - --site my-site \ 200 - --directory 184 + Options: 185 + -s, --site <SITE> Site name to download 186 + -p, --path <PATH> Output directory [default: .] 201 187 ``` 202 188 203 - Downloads site, serves it, and watches firehose for live updates! 204 - 205 - ## Authentication 206 - 207 - ### OAuth (Recommended) 208 - 209 - The CLI uses OAuth by default, opening your browser for secure authentication: 189 + ### serve 210 190 211 - ```bash 212 - wisp-cli deploy your-handle.bsky.social --path ./dist --site my-site 213 191 ``` 192 + wispctl serve [OPTIONS] --site <SITE> <INPUT> 214 193 215 - This creates a session stored locally (default: `/tmp/wisp-oauth-session.json`). 194 + Arguments: 195 + <INPUT> Handle or DID 216 196 217 - ### App Password 218 - 219 - For headless environments or CI/CD, use an app password: 220 - 221 - ```bash 222 - wisp-cli deploy your-handle.bsky.social \ 223 - --path ./dist \ 224 - --site my-site \ 225 - --password YOUR_APP_PASSWORD 197 + Options: 198 + -s, --site <SITE> Site name 199 + -p, --path <PATH> Site files directory [default: .] 200 + -P, --port <PORT> Port [default: 8080] 201 + --spa Serve index.html for all routes 202 + --directory Directory listing for paths without index files 226 203 ``` 227 204 228 - **Generate app passwords** from your AT Protocol account settings. 229 - 230 - ## File Processing 231 - 232 - The CLI handles all file processing automatically to ensure reliable storage and delivery. Files are compressed with gzip at level 9 for optimal size reduction, then base64 encoded to bypass PDS content sniffing restrictions. Everything is uploaded as `application/octet-stream` blobs while preserving the original MIME type as metadata. When serving your site, the hosting service automatically decompresses non-HTML/CSS/JS files, ensuring your content is delivered correctly to visitors. 205 + ## Binary Downloads 233 206 234 - **File Filtering**: The CLI automatically excludes common files like `.git`, `node_modules`, `.env`, and other development artifacts. Customize this with a [`.wispignore` file](/file-filtering). 207 + Pre-built binaries are available if you can't use npm. 235 208 236 - ## Incremental Updates 209 + <div class="downloads"> 237 210 238 - The CLI tracks file changes using CID-based content addressing to minimize upload times and bandwidth usage. On your first deploy, all files are uploaded to establish the initial site. For subsequent deploys, the CLI compares content-addressed CIDs to detect which files have actually changed, uploading only those that differ from the previous version. This makes fast iterations possible even for large sites, with deploys completing in seconds when only a few files have changed. 211 + <h2>Download v1.0.0</h2> 239 212 240 - ## Limits 213 + <a href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-darwin" class="download-link" download=""> 241 214 242 - - **Max file size**: 100MB per file (after compression) 243 - - **Max total size**: 300MB per site 244 - - **Max files**: 1000 files per site 245 - - **Site name**: Must follow AT Protocol rkey format (alphanumeric, hyphens, underscores) 215 + <span class="platform">macOS (Apple Silicon):</span> wisp-cli-aarch64-darwin 246 216 247 - ## Command Reference 217 + </a> 248 218 249 - ### Deploy Command 219 + <a href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-darwin" class="download-link" download=""> 250 220 251 - ```bash 252 - wisp-cli deploy [OPTIONS] <INPUT> 221 + <span class="platform">macOS (Intel):</span> wisp-cli-x86_64-darwin 253 222 254 - Arguments: 255 - <INPUT> Handle (e.g., alice.bsky.social), DID, or PDS URL 223 + </a> 256 224 257 - Options: 258 - -p, --path <PATH> Path to site directory [default: .] 259 - -s, --site <SITE> Site name (defaults to directory name) 260 - --store <STORE> OAuth session file path [default: /tmp/wisp-oauth-session.json] 261 - --password <PASSWORD> App password for authentication 262 - -h, --help Print help 263 - ``` 225 + <a href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux" class="download-link" download=""> 264 226 265 - ### Pull Command 227 + <span class="platform">Linux (ARM64):</span> wisp-cli-aarch64-linux 266 228 267 - ```bash 268 - wisp-cli pull [OPTIONS] --site <SITE> <INPUT> 229 + </a> 269 230 270 - Arguments: 271 - <INPUT> Handle or DID 231 + <a href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux" class="download-link" download=""> 272 232 273 - Options: 274 - -s, --site <SITE> Site name to download 275 - -p, --path <PATH> Output directory [default: .] 276 - -h, --help Print help 277 - ``` 233 + <span class="platform">Linux (x86_64):</span> wisp-cli-x86_64-linux 278 234 279 - ### Serve Command 235 + </a> 280 236 281 - ```bash 282 - wisp-cli serve [OPTIONS] --site <SITE> <INPUT> 237 + <h3 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">SHA-256 Checksums</h3> 283 238 284 - Arguments: 285 - <INPUT> Handle or DID 239 + <pre style="font-size: 0.75rem; padding: 1rem;" class="language-bash" tabindex="0"><code class="language-bash"> 240 + 06544b3a3e27a4b8d7b3a46a39fb7205cf90b3061e19fe533b090facd604f375 wisp-cli-aarch64-darwin 241 + 9ec523e3ceef927b37adc52d449dcd9e13ea84fa49b0b77f0d5932c94cfe262e wisp-cli-x86_64-darwin 242 + 42a262668e13dce36173a4096cdc2b22358b805cf192335f84534c7f695d395b wisp-cli-aarch64-linux 243 + 589ee59f3959ddfbc12fea38d2bcb91701f1362f560ae6fd506bebea3150e2cc wisp-cli-x86_64-linux 244 + </code></pre> 286 245 287 - Options: 288 - -s, --site <SITE> Site name to serve 289 - -p, --path <PATH> Site files directory [default: .] 290 - -P, --port <PORT> Port to serve on [default: 8080] 291 - --spa Enable SPA mode (serve index.html for all routes) 292 - --directory Enable directory listing mode for paths without index files 293 - -h, --help Print help 294 - ``` 246 + </div> 295 247 296 - ## Development 248 + ## Building from Source 297 249 298 - The CLI is written in Rust using the Jacquard AT Protocol library. To build from source: 250 + The CLI is written in TypeScript and supports both Node.js and Bun runtimes. Run directly with Bun during development, or build a Node.js-compatible bundle for distribution. 299 251 300 252 ```bash 301 253 git clone https://tangled.org/@nekomimi.pet/wisp.place-monorepo 302 254 cd cli 303 - cargo build --release 304 - ``` 255 + bun install 305 256 306 - Built binaries are available in `target/release/`. 257 + # Run directly with Bun 258 + bun run index.ts 307 259 308 - ## Related 309 - 310 - - [place.wisp.fs](/lexicons/place-wisp-fs) - Site manifest lexicon 311 - - [place.wisp.subfs](/lexicons/place-wisp-subfs) - Subtree records for large sites 312 - - [AT Protocol](https://atproto.com) - The decentralized protocol powering Wisp 260 + # Build a Node.js bundle (outputs to dist/) 261 + bun run build 262 + node dist/index.js 263 + ```
+62 -272
docs/src/content/docs/deployment.md
··· 1 1 --- 2 - title: Self-Hosting Guide 2 + title: Self-Hosting 3 3 description: Deploy your own Wisp.place instance 4 4 --- 5 5 6 - This guide covers deploying your own Wisp.place instance. Wisp.place consists of three services: the main backend (handles OAuth, uploads, domains), the firehose service (watches the AT Protocol firehose and populates the cache), and the hosting service (serves cached sites). See the [Architecture Guide](/architecture) for a detailed breakdown of how these services work together. 7 - 8 - ## Prerequisites 9 - 10 - - **PostgreSQL** database (14 or newer) 11 - - **Bun** runtime for the main backend and firehose service 12 - - **Node.js** (18+) for the hosting service 13 - - **Caddy** (optional, for custom domain TLS) 14 - - **Domain name** for your instance 15 - - **S3-compatible storage** (optional, recommended for production — Cloudflare R2, MinIO, etc.) 16 - - **Redis** (optional, for real-time cache invalidation between services) 17 - 18 - ## Architecture Overview 6 + Wisp.place consists of three services: the **main backend** handles OAuth, uploads, and domain management; the **firehose service** watches the AT Protocol firehose and populates the cache; the **hosting service** serves cached sites. See [Architecture](/architecture) for how they fit together. 19 7 20 8 ``` 21 9 ┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐ ··· 30 18 └────────┬───────────────┘ └─────────────────────┘ 31 19 32 20 ┌─────────────────────────────────────────┐ 33 - │ PostgreSQL Database │ 34 - │ - User sessions │ 21 + │ PostgreSQL │ 22 + │ - OAuth sessions + keys │ 35 23 │ - Domain mappings │ 36 24 │ - Site metadata │ 37 25 └─────────────────────────────────────────┘ 38 26 ``` 39 27 40 - ## Database Setup 28 + **You'll need:** PostgreSQL 14+, Bun (main backend + firehose), Node.js 18+ (hosting service), and a domain. S3-compatible storage (Cloudflare R2, MinIO, etc.) and Redis are optional but recommended for production. 41 29 42 - Create a PostgreSQL database for Wisp.place: 30 + ## Database 43 31 44 32 ```bash 45 33 createdb wisp 46 34 ``` 47 35 48 - The schema is automatically created on first run. Tables include: 49 - - `oauth_states`, `oauth_sessions`, `oauth_keys` - OAuth flow 50 - - `domains` - Wisp subdomains (*.yourdomain.com) 51 - - `custom_domains` - User custom domains with DNS verification 52 - - `sites` - Site metadata cache 53 - - `cookie_secrets` - Session signing keys 36 + The schema is created automatically on first run. 54 37 55 - ## Main Backend Setup 56 - 57 - ### Environment Variables 58 - 59 - Create a `.env` file or set these environment variables: 38 + ## Main Backend 60 39 61 40 ```bash 62 41 # Required 63 42 DATABASE_URL="postgres://user:password@localhost:5432/wisp" 64 - BASE_DOMAIN="wisp.place" # Your domain (without protocol) 65 - DOMAIN="https://wisp.place" # Full domain with protocol 66 - CLIENT_NAME="Wisp.place" # OAuth client name 43 + BASE_DOMAIN="wisp.place" 44 + DOMAIN="https://wisp.place" 45 + CLIENT_NAME="Wisp.place" 67 46 68 47 # Optional 69 - NODE_ENV="production" # production or development 70 - PORT="8000" # Default: 8000 48 + NODE_ENV="production" 49 + PORT="8000" 71 50 ``` 72 51 73 - ### Installation 74 - 75 52 ```bash 76 - # Install dependencies 77 53 bun install 78 - 79 - # Development mode (with hot reload) 80 - bun run dev 81 - 82 - # Production mode 83 - bun run start 84 - 85 - # Or compile to binary 86 - bun run build 87 - ./server 88 - ``` 89 - 90 - The backend will: 91 - 1. Initialize the database schema 92 - 2. Generate OAuth keys (stored in DB) 93 - 3. Start DNS verification worker (checks custom domains every 10 minutes) 94 - 4. Listen on port 8000 95 - 96 - ### First-Time Admin Setup 97 - 98 - On first run, you'll be prompted to create an admin account: 99 - 100 - ``` 101 - No admin users found. Create one now? (y/n): 54 + bun run start # production 55 + bun run dev # dev with hot reload 56 + bun run build # compile to a binary 102 57 ``` 103 58 104 - Or create manually: 59 + On first run you'll be prompted to create an admin account. You can also run it manually: 105 60 106 61 ```bash 107 62 bun run scripts/create-admin.ts 108 63 ``` 109 64 110 - Admin panel is available at `https://yourdomain.com/admin` 65 + Admin panel is at `https://yourdomain.com/admin`. 111 66 112 - ## Firehose Service Setup 113 - 114 - The firehose service watches the AT Protocol firehose for site changes and pre-populates the cache. It is **write-only** — it never serves requests to users. 115 - 116 - ### Environment Variables 67 + ## Firehose Service 117 68 118 69 ```bash 119 70 # Required 120 71 DATABASE_URL="postgres://user:password@localhost:5432/wisp" 121 72 122 - # S3 storage (recommended for production) 73 + # S3 storage (recommended) 123 74 S3_BUCKET="wisp-sites" 124 - S3_REGION="auto" 75 + S3_REGION="us-east-1" 125 76 S3_ENDPOINT="https://your-account.r2.cloudflarestorage.com" 126 - S3_ACCESS_KEY_ID="..." 127 - S3_SECRET_ACCESS_KEY="..." 128 - S3_METADATA_BUCKET="wisp-metadata" # Optional, recommended 77 + S3_FORCE_PATH_STYLE="false" # set true for MinIO and most non-AWS endpoints 78 + S3_PREFIX="sites/" 79 + AWS_ACCESS_KEY_ID="..." 80 + AWS_SECRET_ACCESS_KEY="..." 129 81 130 - # Redis (for notifying hosting service of changes) 82 + # Redis (for notifying the hosting service of changes) 131 83 REDIS_URL="redis://localhost:6379" 132 84 133 - # Firehose 134 - FIREHOSE_URL="wss://jetstream2.us-east.bsky.network/subscribe" 135 - FIREHOSE_CONCURRENCY=5 # Max parallel event processing 85 + FIREHOSE_SERVICE="wss://bsky.network" 86 + FIREHOSE_MAX_CONCURRENCY=5 136 87 137 - # Optional 138 - CACHE_DIR="./cache/sites" # Fallback if S3 not configured 88 + HEALTH_PORT=3001 89 + 90 + # Fallback disk path if S3 is not configured 91 + CACHE_DIR="./cache/sites" 139 92 ``` 140 93 141 - ### Installation 142 - 143 94 ```bash 144 95 cd firehose-service 145 - 146 - # Install dependencies 147 96 bun install 148 - 149 - # Production mode 150 97 bun run start 151 - 152 - # With backfill (one-time bulk sync of all existing sites) 153 - bun run start -- --backfill 98 + bun run start -- --backfill # one-time bulk sync of all existing sites 154 99 ``` 155 100 156 - The firehose service will: 157 - 1. Connect to the AT Protocol firehose (Jetstream) 158 - 2. Filter for `place.wisp.fs` and `place.wisp.settings` events 159 - 3. Download blobs, decompress, and rewrite HTML paths 160 - 4. Write files to S3 (or disk) 161 - 5. Publish cache invalidation events to Redis 162 - 163 - ## Hosting Service Setup 164 - 165 - The hosting service is a **read-only** CDN that serves cached sites through a three-tier storage system (memory, disk, S3). 166 - 167 - ### Environment Variables 101 + ## Hosting Service 168 102 169 103 ```bash 170 104 # Required 171 105 DATABASE_URL="postgres://user:password@localhost:5432/wisp" 172 - BASE_HOST="wisp.place" # Same as main backend 106 + BASE_HOST="wisp.place" 107 + PORT=3001 173 108 174 109 # Tiered storage 175 - HOT_CACHE_SIZE=104857600 # Hot tier: 100 MB (memory, LRU) 176 - HOT_CACHE_COUNT=500 # Max items in hot tier 110 + CACHE_DIR="./cache/sites" 111 + HOT_CACHE_SIZE=104857600 # 100 MB, in-memory LRU 112 + HOT_CACHE_COUNT=500 113 + HOT_CACHE_TTL=60 # seconds 114 + WARM_CACHE_SIZE=10737418240 # 10 GB, disk 115 + WARM_EVICTION_POLICY="lru" # lru, fifo, or size 177 116 178 - WARM_CACHE_SIZE=10737418240 # Warm tier: 10 GB (disk, LRU) 179 - WARM_EVICTION_POLICY="lru" # lru, fifo, or size 180 - CACHE_DIR="./cache/sites" # Warm tier directory 117 + # Bootstrap hot tier from warm on startup 118 + BOOTSTRAP_HOT_ON_STARTUP=false 119 + BOOTSTRAP_HOT_LIMIT=100 181 120 182 - # S3 cold tier (same bucket as firehose service, read-only) 121 + # S3 cold tier (same bucket as firehose, read-only) 183 122 S3_BUCKET="wisp-sites" 184 - S3_REGION="auto" 123 + S3_REGION="us-east-1" 185 124 S3_ENDPOINT="https://your-account.r2.cloudflarestorage.com" 186 - S3_ACCESS_KEY_ID="..." 187 - S3_SECRET_ACCESS_KEY="..." 188 - S3_METADATA_BUCKET="wisp-metadata" 125 + S3_FORCE_PATH_STYLE="false" 126 + S3_PREFIX="sites/" 127 + AWS_ACCESS_KEY_ID="..." 128 + AWS_SECRET_ACCESS_KEY="..." 189 129 190 - # Redis (receive cache invalidation from firehose service) 191 130 REDIS_URL="redis://localhost:6379" 192 131 193 132 # Optional 194 - PORT="3001" # Default: 3001 133 + CACHE_ONLY=false # serve from cache only, no PDS fallback 134 + TRACE_REQUESTS=false 195 135 ``` 196 136 197 - ### Installation 198 - 199 137 ```bash 200 138 cd hosting-service 201 - 202 - # Install dependencies 203 139 npm install 204 - 205 - # Development mode 206 - npm run dev 207 - 208 - # Production mode 209 140 npm run start 210 141 ``` 211 142 212 - The hosting service will: 213 - 1. Initialize tiered storage (hot → warm → cold) 214 - 2. Subscribe to Redis for cache invalidation events 215 - 3. Serve sites on port 3001 216 - 217 - ### Cache Behavior 218 - 219 - Files are cached across three tiers with automatic promotion: 220 - 221 - - **Hot (memory):** Fastest, limited by `HOT_CACHE_SIZE`. Evicted on restart. 222 - - **Warm (disk):** Fast local reads at `CACHE_DIR`. Survives restarts. 223 - - **Cold (S3):** Shared source of truth, populated by firehose service. 224 - 225 - On a cache miss at all tiers, the hosting service fetches directly from the user's PDS and promotes the file into the appropriate tiers. 226 - 227 - **Without S3:** Disk acts as both warm and cold tier. The hosting service still works — it just relies on on-demand fetching instead of pre-populated S3 cache. 143 + ## Reverse Proxy 228 144 229 - ## Reverse Proxy Setup 230 - 231 - ### Caddy Configuration 232 - 233 - Caddy handles TLS, on-demand certificates for custom domains, and routing: 145 + Caddy is the recommended reverse proxy — it handles TLS and on-demand certificates for custom domains automatically. 234 146 235 147 ``` 236 148 { ··· 239 151 } 240 152 } 241 153 242 - # Wisp subdomains and DNS hash routing 243 154 *.dns.wisp.place *.wisp.place { 244 155 reverse_proxy localhost:3001 245 156 } 246 157 247 - # Main web interface and API 248 158 wisp.place { 249 159 reverse_proxy localhost:8000 250 160 } 251 161 252 - # Custom domains (on-demand TLS) 253 162 https:// { 254 163 tls { 255 164 on_demand ··· 258 167 } 259 168 ``` 260 169 261 - ### Nginx Alternative 170 + Nginx works too, but custom domain TLS requires dynamic certificate provisioning that you'll need to manage separately. 262 171 263 172 ```nginx 264 - # Main backend 265 173 server { 266 174 listen 443 ssl http2; 267 175 server_name wisp.place; 268 - 269 176 ssl_certificate /path/to/cert.pem; 270 177 ssl_certificate_key /path/to/key.pem; 271 - 272 178 location / { 273 179 proxy_pass http://localhost:8000; 274 180 proxy_set_header Host $host; ··· 276 182 } 277 183 } 278 184 279 - # Hosting service 280 185 server { 281 186 listen 443 ssl http2; 282 187 server_name *.wisp.place sites.wisp.place; 283 - 284 188 ssl_certificate /path/to/wildcard-cert.pem; 285 189 ssl_certificate_key /path/to/wildcard-key.pem; 286 - 287 190 location / { 288 191 proxy_pass http://localhost:3001; 289 192 proxy_set_header Host $host; ··· 292 195 } 293 196 ``` 294 197 295 - **Note:** Custom domain TLS requires dynamic certificate provisioning. Caddy's on-demand TLS is the easiest solution. 296 - 297 - ## OAuth Configuration 298 - 299 - Wisp.place uses AT Protocol OAuth. Your instance needs to be publicly accessible for OAuth callbacks. 300 - 301 - Required endpoints: 302 - - `/.well-known/atproto-did` - Returns your DID for lexicon resolution 303 - - `/oauth-client-metadata.json` - OAuth client metadata 304 - - `/jwks.json` - OAuth signing keys 305 - 306 - These are automatically served by the backend. 307 - 308 - ## DNS Configuration 309 - 310 - For your main domain: 198 + ## DNS 311 199 312 200 ``` 313 201 wisp.place A YOUR_SERVER_IP ··· 316 204 sites.wisp.place A YOUR_SERVER_IP 317 205 ``` 318 206 319 - Or use CNAME records if you're behind a CDN: 207 + ## OAuth 320 208 321 - ``` 322 - wisp.place CNAME your-server.example.com 323 - *.wisp.place CNAME your-server.example.com 324 - ``` 209 + Your instance needs to be publicly accessible for OAuth callbacks. The backend automatically serves `/.well-known/atproto-did`, `/oauth-client-metadata.json`, and `/jwks.json`. 325 210 326 211 ## Custom Domain Verification 327 212 328 - Users can add custom domains via DNS TXT records: 213 + Users add custom domains by creating a DNS TXT record: 329 214 330 215 ``` 331 216 _wisp.example.com TXT did:plc:abc123xyz... 332 217 ``` 333 218 334 - The DNS verification worker checks these every 10 minutes. Trigger manually: 219 + The verification worker checks every 10 minutes. Trigger it manually: 335 220 336 221 ```bash 337 222 curl -X POST https://yourdomain.com/api/admin/verify-dns 338 - ``` 339 - 340 - ## Production Checklist 341 - 342 - Before going live: 343 - 344 - - [ ] PostgreSQL database configured with backups 345 - - [ ] `DATABASE_URL` set with secure credentials 346 - - [ ] `BASE_DOMAIN` and `DOMAIN` configured correctly 347 - - [ ] Admin account created 348 - - [ ] Reverse proxy (Caddy/Nginx) configured 349 - - [ ] DNS records pointing to your server 350 - - [ ] TLS certificates configured 351 - - [ ] Hosting service cache directory has sufficient space 352 - - [ ] Firewall allows ports 80/443 353 - - [ ] Process manager (systemd, pm2) configured for auto-restart 354 - 355 - ## Monitoring 356 - 357 - ### Health Checks 358 - 359 - Main backend: 360 - ```bash 361 - curl https://yourdomain.com/api/health 362 - ``` 363 - 364 - Hosting service: 365 - ```bash 366 - curl http://localhost:3001/health 367 - ``` 368 - 369 - ### Logs 370 - 371 - The services log to stdout. View with your process manager: 372 - 373 - ```bash 374 - # systemd 375 - journalctl -u wisp-backend -f 376 - journalctl -u wisp-hosting -f 377 - 378 - # pm2 379 - pm2 logs wisp-backend 380 - pm2 logs wisp-hosting 381 - ``` 382 - 383 - ### Admin Panel 384 - 385 - Access observability metrics at `https://yourdomain.com/admin`: 386 - - Recent logs 387 - - Error tracking 388 - - Performance metrics 389 - - Cache statistics 390 - 391 - ## Scaling Considerations 392 - 393 - - **Multiple hosting instances**: Run multiple hosting services behind a load balancer — each has its own hot/warm tiers but shares the S3 cold tier and Redis invalidation 394 - - **Separate databases**: Split read/write with replicas 395 - - **CDN**: Put Cloudflare or Bunny in front for global caching 396 - - **S3 cold tier**: Shared storage across all hosting instances (Cloudflare R2, MinIO, AWS S3) 397 - - **Redis**: Required for real-time cache invalidation between firehose and hosting services at scale 398 - 399 - ## Security Notes 400 - 401 - - Use strong cookie secrets (auto-generated and stored in DB) 402 - - Keep dependencies updated: `bun update`, `npm update` 403 - - Enable rate limiting in reverse proxy 404 - - Set up fail2ban for brute force protection 405 - - Regular database backups 406 - - Monitor logs for suspicious activity 407 - 408 - ## Updates 409 - 410 - To update your instance: 411 - 412 - ```bash 413 - # Pull latest code 414 - git pull 415 - 416 - # Update dependencies 417 - bun install 418 - cd hosting-service && npm install && cd .. 419 - 420 - # Restart services 421 - # (The database schema updates automatically) 422 - ``` 423 - 424 - ## Support 425 - 426 - For issues and questions: 427 - - Check the [documentation](https://docs.wisp.place) 428 - - Review [Tangled issues](https://tangled.org/nekomimi.pet/wisp.place-monorepo) 429 - - Join the [Bluesky community](https://bsky.app) 430 - 431 - ## License 432 - 433 - Wisp.place is MIT licensed. You're free to host your own instance and modify it as needed. 223 + ```
+6 -41
docs/src/content/docs/file-filtering.md
··· 3 3 description: Control which files are uploaded to your Wisp site 4 4 --- 5 5 6 - # File Filtering & .wispignore 7 - 8 - Wisp automatically excludes common files that shouldn't be deployed (`.git`, `node_modules`, `.env`, etc.). 9 - 10 - ## Default Exclusions 11 - 12 - - Version control: `.git`, `.github`, `.gitlab` 13 - - Dependencies: `node_modules`, `__pycache__`, `*.pyc` 14 - - Secrets: `.env`, `.env.*` 15 - - OS files: `.DS_Store`, `Thumbs.db`, `._*` 16 - - Cache: `.cache`, `.temp`, `.tmp` 17 - - Dev tools: `.vscode`, `*.swp`, `*~`, `.tangled` 18 - - Virtual envs: `.venv`, `venv`, `env` 19 - 20 - ## Custom Patterns 6 + Wisp automatically excludes common files that shouldn't be deployed — version control (`.git`, `.github`), dependencies (`node_modules`, `__pycache__`), secrets (`.env`, `.env.*`), OS files (`.DS_Store`, `Thumbs.db`), caches, and dev tooling. 21 7 22 - Create a `.wispignore` file in your site root using gitignore syntax: 8 + To exclude additional files, create a `.wispignore` in your site root using gitignore syntax: 23 9 24 10 ``` 25 - # Build outputs 11 + # Build artifacts 26 12 dist/ 27 13 *.map 28 14 29 - # Logs and temp files 15 + # Logs 30 16 *.log 31 17 temp/ 32 18 33 - # Keep one exception 19 + # Keep a specific file 34 20 !important.log 35 21 ``` 36 22 37 - ### Pattern Syntax 38 - 39 - - `file.txt` - exact match 40 - - `*.log` - wildcard 41 - - `logs/` - directory 42 - - `src/**/*.test.js` - glob pattern 43 - - `!keep.txt` - exception (don't ignore) 44 - 45 - ## Usage 46 - 47 - **CLI**: Place `.wispignore` in your upload directory 48 - ```bash 49 - wisp-cli handle.bsky.social --path ./my-site --site my-site 50 - ``` 51 - 52 - **Web**: Include `.wispignore` when uploading files 53 - 54 - ## Notes 55 - 56 - - Custom patterns add to (not replace) default patterns 57 - - Works in both CLI and web uploads 58 - - The CLI logs which files are skipped 23 + Custom patterns are added on top of the defaults, not in place of them. The CLI logs which files are skipped.
-11
docs/src/content/docs/guides/example.md
··· 1 - --- 2 - title: Example Guide 3 - description: A guide in my new Starlight docs site. 4 - --- 5 - 6 - Guides lead a user through a specific task they want to accomplish, often with a sequence of steps. 7 - Writing a good guide requires thinking about what your users are trying to do. 8 - 9 - ## Further reading 10 - 11 - - Read [about how-to guides](https://diataxis.fr/how-to-guides/) in the Diátaxis framework
+1 -143
docs/src/content/docs/index.mdx
··· 26 26 Your site will be available at: 27 27 ``` 28 28 https://sites.wisp.place/{your-did}/{site-name} 29 - ``` 30 - 31 - ## Key Features 32 - 33 - ### Decentralized Storage 34 - - Sites stored as `place.wisp.fs` records in your AT Protocol repo 35 - - Cryptographically verifiable ownership 36 - - Your PDS is the source of truth 37 - - Portable across hosting providers 38 - 39 - ### Custom Domains 40 - - Point your own domain to your Wisp site 41 - - DNS verification ensures secure ownership 42 - - Automatic SSL/TLS certificates 43 - 44 - ### URL Redirects & Rewrites 45 - - Netlify-style `_redirects` file support 46 - - Single-page app (SPA) routing 47 - - API proxying and conditional routing 48 - - Custom 404 pages 49 - 50 - ### Efficient Deployment 51 - - **Incremental updates**: Only upload changed files 52 - - **Smart compression**: Automatic gzip for text files 53 - - **Large site support**: Automatic splitting into subfs records for sites with 250+ files to get around 150KB record size limit 54 - - **Blob reuse**: Content-addressed storage prevents duplicate uploads 55 - 56 - ## How It Works 57 - 58 - The deployment process starts when you upload your files. Each file is compressed with gzip, base64-encoded, and uploaded as a blob to your PDS. A `place.wisp.fs` record then stores the complete site structure with references to these blobs, creating a verifiable manifest of your site. 59 - 60 - The **firehose service** continuously watches the AT Protocol firehose for new and updated sites. When a site is created or updated, it downloads the manifest and blobs from the PDS, writes them to S3 (or disk), and publishes a cache invalidation event via Redis. The **hosting service** is a read-only CDN that serves files from a three-tier cache (memory, disk, S3). When a file isn't in cache, the hosting service fetches it on-demand from the PDS and promotes it through the tiers. 61 - 62 - Custom domains work through DNS verification, allowing your site to be served from your own domain while maintaining the cryptographic guarantees of the AT Protocol. 63 - 64 - ## Architecture Overview 65 - 66 - ``` 67 - ┌──────────────┐ ┌──────────────┐ 68 - │ wisp-cli │ │ wisp.place │ 69 - │ (Rust Binary)│ │ Website │ 70 - │ │ │ (React UI) │ 71 - └──────────────┘ └──────────────┘ 72 - │ │ 73 - ▼ ▼ 74 - ┌─────────────────────────────────────────────────────────┐ 75 - │ AT Protocol PDS │ 76 - │ (Authoritative Source - Cryptographically Signed) │ 77 - │ │ 78 - │ ┌──────────────────────────────────────────────┐ │ 79 - │ │ place.wisp.fs record │ │ 80 - │ │ - Site manifest (directory tree) │ │ 81 - │ │ - Blob references (CID-based) │ │ 82 - │ │ - Metadata (file count, timestamps) │ │ 83 - │ └──────────────────────────────────────────────┘ │ 84 - │ │ 85 - │ ┌──────────────────────────────────────────────┐ │ 86 - │ │ Blobs (gzipped + base64 encoded) │ │ 87 - │ │ - index.html, styles.css, assets/* │ │ 88 - │ └──────────────────────────────────────────────┘ │ 89 - └─────────────────────────────────────────────────────────┘ 90 - 91 - 92 - ┌─────────────────────────────────┐ 93 - │ AT Protocol Firehose │ 94 - │ (Jetstream WebSocket Stream) │ 95 - └─────────────────────────────────┘ 96 - 97 - 98 - ┌─────────────────────────────────────────────────────────┐ 99 - │ Firehose Service (Write Path) │ 100 - │ │ 101 - │ - Watches firehose for place.wisp.fs changes │ 102 - │ - Downloads blobs from PDS │ 103 - │ - Writes cached files to S3 / disk │ 104 - │ - Publishes cache invalidation via Redis │ 105 - └─────────────────────────────────────────────────────────┘ 106 - │ │ 107 - │ (S3 / Disk) │ (Redis pub/sub) 108 - ▼ ▼ 109 - ┌─────────────────────────────────────────────────────────┐ 110 - │ Hosting Service (Read Path) │ 111 - │ │ 112 - │ ┌──────────────────────────────────────────────┐ │ 113 - │ │ Tiered Storage │ │ 114 - │ │ ┌──────┐ ┌──────┐ ┌──────────────┐ │ │ 115 - │ │ │ Hot │ → │ Warm │ → │ Cold │ │ │ 116 - │ │ │(Mem) │ │(Disk)│ │(S3/Disk) │ │ │ 117 - │ │ └──────┘ └──────┘ └──────────────┘ │ │ 118 - │ │ On miss: fetch from PDS and promote up │ │ 119 - │ └──────────────────────────────────────────────┘ │ 120 - │ │ 121 - │ ┌──────────────────────────────────────────────┐ │ 122 - │ │ Routing & Serving │ │ 123 - │ │ - Custom domains (example.com) │ │ 124 - │ │ - Subdomains (alice.wisp.place) │ │ 125 - │ │ - Direct URLs (sites.wisp.place/did/site) │ │ 126 - │ └──────────────────────────────────────────────┘ │ 127 - └─────────────────────────────────────────────────────────┘ 128 - 129 - 130 - ┌─────────────┐ 131 - │ Browser │ 132 - └─────────────┘ 133 - ``` 134 - 135 - For a detailed breakdown of the services and storage system, see the [Architecture Guide](/architecture). 136 - 137 - ## Tech Stack 138 - 139 - - **Backend**: Bun + Elysia + PostgreSQL 140 - - **Frontend**: React 19 + Tailwind 4 + Radix UI 141 - - **Hosting Service**: Node.js + Hono 142 - - **Firehose Service**: Bun 143 - - **CLI**: Rust + Jacquard (AT Protocol library) 144 - - **Protocol**: AT Protocol OAuth + custom lexicons 145 - - **Storage**: S3-compatible (Cloudflare R2, MinIO, etc.) + Redis for cache invalidation 146 - 147 - ## Limits 148 - 149 - - **Max file size**: 100MB per file (PDS limit) 150 - - **Max files**: 1000 files per site 151 - - **Max total size**: 300MB per site (compressed) 152 - 153 - Files are automatically compressed with gzip before upload, so actual limits may be higher depending on your content compressibility. 154 - 155 - ## Getting Started 156 - 157 - - [CLI Documentation](/cli) - Deploy sites from the command line 158 - - [Architecture Guide](/architecture) - How hosting, firehose, and tiered storage work 159 - - [Self-Hosting Guide](/deployment) - Deploy your own instance 160 - - [Lexicons](/lexicons) - AT Protocol record schemas and data structures 161 - 162 - ## Links 163 - 164 - - **Website**: [https://wisp.place](https://wisp.place) 165 - - **Repository**: [https://tangled.org/@nekomimi.pet/wisp.place-monorepo](https://tangled.org/@nekomimi.pet/wisp.place-monorepo) 166 - - **AT Protocol**: [https://atproto.com](https://atproto.com) 167 - - **Jacquard Library**: [https://tangled.org/@nonbinary.computer/jacquard](https://tangled.org/@nonbinary.computer/jacquard) 168 - 169 - ## License 170 - 171 - MIT License - See the repository for details. 29 + ```
+9 -46
docs/src/content/docs/lexicons/index.md
··· 3 3 description: AT Protocol lexicons used by Wisp.place 4 4 --- 5 5 6 - Wisp.place uses custom AT Protocol lexicons to store and manage static site data. These lexicons define the structure of records stored in your PDS. 6 + Wisp.place uses three custom AT Protocol lexicons to store site data in your PDS. 7 7 8 - ## Available Lexicons 9 - 10 - ### [place.wisp.fs](/lexicons/place-wisp-fs) 11 - The main lexicon for storing static site manifests. Contains the directory tree structure with references to file blobs. 8 + **[place.wisp.fs](/lexicons/place-wisp-fs)** — the main site manifest. Stores the full directory tree with references to file blobs. 12 9 13 - ### [place.wisp.subfs](/lexicons/place-wisp-subfs) 14 - Subtree lexicon for splitting large sites across multiple records. Entries from subfs records are merged (flattened) into the parent directory. 10 + **[place.wisp.subfs](/lexicons/place-wisp-subfs)** — subtree records for splitting large sites across multiple records. Entries from subfs records are merged into the parent directory. 15 11 16 - ### [place.wisp.domain](/lexicons/place-wisp-domain) 17 - Domain registration record for claiming wisp.place subdomains. 12 + **[place.wisp.domain](/lexicons/place-wisp-domain)** — metadata record for claiming a wisp.place subdomain. 18 13 19 - ## How Lexicons Work 14 + **[place.wisp.v2.wh](/lexicons/place-wisp-wh)** — webhook record for receiving HTTP callbacks when AT Protocol records change. 20 15 21 - ### Storage Model 16 + ## Storage Model 22 17 23 18 Sites are stored as `place.wisp.fs` records in your AT Protocol repository: 24 19 ··· 26 21 at://did:plc:abc123/place.wisp.fs/my-site 27 22 ``` 28 23 29 - Each record contains: 30 - - **Site metadata** (name, file count, timestamps) 31 - - **Directory tree** (hierarchical structure) 32 - - **Blob references** (content-addressed file storage) 24 + Files are gzipped for compression and uploaded as `application/octet-stream` blobs. They may also be base64-encoded to bypass content sniffing on legacy reference PDS. The original MIME type is preserved in the manifest. 33 25 34 - ### File Processing 26 + Sites with 250+ files are automatically split: large directories are extracted into `place.wisp.subfs` records, referenced by AT-URI from the main manifest, and merged back together by the hosting service at serve time. This keeps the main manifest under the 150 KB PDS record size limit. 35 27 36 - 1. Files are **gzipped** for compression 37 - 2. Text files are **base64 encoded** to bypass PDS content sniffing 38 - 3. Uploaded as blobs with `application/octet-stream` MIME type 39 - 4. Original MIME type stored in manifest metadata 40 - 41 - ### Large Site Splitting 42 - 43 - Sites with 250+ files are automatically split: 44 - 45 - 1. Large directories are extracted into `place.wisp.subfs` records 46 - 2. Main manifest references subfs records via AT-URI 47 - 3. Hosting services merge (flatten) subfs entries when serving 48 - 4. Keeps manifest size under 150KB PDS limit 49 - 50 - ## Example Record Structure 28 + ## Example Record 51 29 52 30 ```json 53 31 { ··· 70 48 "mimeType": "text/html", 71 49 "base64": true 72 50 } 73 - }, 74 - { 75 - "name": "assets", 76 - "node": { 77 - "type": "directory", 78 - "entries": [...] 79 - } 80 51 } 81 52 ] 82 53 }, ··· 84 55 "createdAt": "2024-01-15T10:30:00Z" 85 56 } 86 57 ``` 87 - 88 - ## Learn More 89 - 90 - - [place.wisp.fs Reference](/lexicons/place-wisp-fs) 91 - - [place.wisp.subfs Reference](/lexicons/place-wisp-subfs) 92 - - [place.wisp.domain Reference](/lexicons/place-wisp-domain) 93 - - [AT Protocol Lexicons](https://atproto.com/specs/lexicon) 94 -
+35 -131
docs/src/content/docs/lexicons/place-wisp-domain.md
··· 3 3 description: Reference for the place.wisp.domain lexicon 4 4 --- 5 5 6 - **Lexicon Version:** 1 7 - 8 - ## Overview 6 + Metadata record for a claimed wisp.place subdomain (e.g. `alice.wisp.place`). The record lives in the user's PDS as an audit trail — actual routing decisions use the PostgreSQL `domains` table, not this record. 9 7 10 - The `place.wisp.domain` lexicon defines **metadata records for wisp.place subdomains**, 11 - such as `alice.wisp.place` or `miku-fan.wisp.place`. 12 - 13 - - **What lives in the PDS:** a small record that says “this DID claimed this domain at this time”. 14 - - **What is authoritative:** the PostgreSQL `domains` table on the wisp.place backend 15 - (routing and availability checks use the DB, not this record). 16 - 17 - Use this page as a schema reference; routing and TLS details are covered elsewhere. 18 - 19 - --- 20 - 21 - ## Record: `main` 22 - 23 - <a name="main"></a> 24 - 25 - ### `main` (record) 8 + **Lexicon version:** 1 26 9 27 - **Type:** `record` 10 + ## main (record) 28 11 29 - **Description:** Metadata record for a claimed wisp.place subdomain. 12 + | Property | Type | Required | Constraints | 13 + | --- | --- | --- | --- | 14 + | `domain` | `string` | ✅ | Full domain, e.g. `alice.wisp.place` | 15 + | `createdAt` | `string` | ✅ | Format: `datetime` | 30 16 31 - **Properties:** 17 + ## Record Key 32 18 33 - | Name | Type | Req'd | Description | Constraints | 34 - | ----------- | -------- | ----- | --------------------------------------------- | ------------------ | 35 - | `domain` | `string` | ✅ | Full domain name, e.g. `alice.wisp.place` | | 36 - | `createdAt` | `string` | ✅ | When the domain was claimed | Format: `datetime` | 19 + The record key is the subdomain label (the part before `.wisp.place`). A DID with multiple subdomains has multiple records: 37 20 38 - --- 21 + ``` 22 + at://did:plc:abc123/place.wisp.domain/alice 23 + at://did:plc:abc123/place.wisp.domain/miku-fan 24 + ``` 39 25 40 - ## Claim Flow & `rkey` 41 - 42 - ### Subdomain claiming (high‑level) 26 + ## Claim Flow 43 27 44 28 When a user claims `handle.wisp.place`: 45 29 46 - 1. **User authenticates** via OAuth (proves DID control). 47 - 2. **Handle is validated**: 48 - - 3–63 characters 49 - - `a-z`, `0-9`, `-` only 50 - - Does not start/end with `-` 51 - - Not in the reserved set (`www`, `api`, `admin`, `static`, `public`, `preview`, …) 52 - 3. **Domain limit enforced:** max 3 wisp.place subdomains per DID. 53 - 4. **Database row created** in `domains`: 30 + 1. User authenticates via OAuth (proves DID control) 31 + 2. Handle is validated (3–63 chars, `a-z0-9-`, no leading/trailing hyphen, not reserved) 32 + 3. Domain limit checked: max 3 wisp.place subdomains per DID 33 + 4. Database row inserted in `domains` 34 + 5. `place.wisp.domain` record written to PDS 54 35 55 - ```sql 56 - INSERT INTO domains (domain, did, rkey) 57 - VALUES ('handle.wisp.place', did, NULL); 58 - ``` 36 + Reserved handles include `www`, `api`, `admin`, `static`, `public`, `preview`, and others. 59 37 60 - 5. **PDS record written** in `place.wisp.domain` as metadata. 38 + ## Domain Rules 61 39 62 - ### Record key (`rkey`) 63 - 64 - The **record key is the normalized handle** (the subdomain label): 65 - 66 - ```text 67 - at://did:plc:abc123/place.wisp.domain/wisp 68 - ``` 69 - 70 - If a DID claims multiple subdomains, it will have multiple records: 71 - 72 - - `at://did:plc:abc123/place.wisp.domain/wisp` 73 - - `at://did:plc:abc123/place.wisp.domain/miku-fan` 74 - 75 - --- 40 + - Length: 3–63 characters 41 + - Characters: `a-z`, `0-9`, `-` 42 + - Must start and end with alphanumeric 43 + - Stored and compared in lowercase 44 + - Max 3 per DID 45 + - Each subdomain can only be owned by one DID 76 46 77 - ## Examples 47 + Valid: `alice`, `my-site`, `dev2024` 48 + Invalid: `ab` (too short), `-alice` (leading hyphen), `alice.bob` (dot), `alice_bob` (underscore) 78 49 79 - ### Basic domain record 50 + ## Example 80 51 81 52 ```json 82 53 { ··· 86 57 } 87 58 ``` 88 59 89 - ### URI structure 90 - 91 - Complete AT-URI for a domain record: 92 - 93 - ```text 94 - at://did:plc:7puq73yz2hkvbcpdhnsze2qw/place.wisp.domain/wisp 95 - ``` 96 - 97 - Breakdown: 98 - 99 - - **`did:plc:7puq73yz2hkvbcpdhnsze2qw`** – User DID 100 - - **`place.wisp.domain`** – Collection ID 101 - - **`wisp`** – Record key (subdomain handle) 102 - 103 - --- 60 + ## Database Schema 104 61 105 - ## Domain rules (summary) 106 - 107 - - **Length:** 3–64 characters 108 - - **Characters:** `a-z`, `0-9`, and `-` 109 - - **Shape:** must start and end with alphanumeric 110 - - **Case:** stored/compared in lowercase 111 - - **Limit:** up to **3** wisp.place subdomains per DID 112 - - **Uniqueness:** each `*.wisp.place` can only be owned by one DID at a time. 113 - 114 - Valid examples: 115 - 116 - - `alice` → `alice.wisp.place` 117 - - `my-site` → `my-site.wisp.place` 118 - - `dev2024` → `dev2024.wisp.place` 119 - 120 - Invalid examples: 121 - 122 - - `ab` (too short) 123 - - `-alice` / `alice-` (leading or trailing hyphen) 124 - - `alice.bob` (dot) 125 - - `alice_bob` (underscore) 126 - 127 - --- 128 - 129 - ## Database & routing 130 - 131 - The **lexicon record is not used for routing**. All real decisions use the DB: 62 + Routing and availability checks use the DB, not the PDS record: 132 63 133 64 ```sql 134 65 CREATE TABLE domains ( 135 66 domain TEXT PRIMARY KEY, -- "alice.wisp.place" 136 - did TEXT NOT NULL, -- User DID 137 - rkey TEXT, -- Site rkey (place.wisp.fs) 67 + did TEXT NOT NULL, 68 + rkey TEXT, -- site rkey (place.wisp.fs) 138 69 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 139 70 ); 140 - 141 - CREATE INDEX domains_did_rkey ON domains (did, rkey); 142 71 ``` 143 72 144 - The `domains` table powers: 145 - 146 - - **Availability checks** (`/api/domain/check`) 147 - - **Mapping** from hostname → `(did, rkey)` for the hosting service 148 - - **Safety:** you must explicitly change/delete routing in the DB, avoiding accidental takeovers. 149 - 150 - The `place.wisp.domain` PDS record is there for: 151 - 152 - - **Audit trail** – who claimed what, when 153 - - **User-visible history** in their repo 154 - - **Optional cross‑checking** against the DB if you care to. 155 - 156 - --- 157 - 158 - ## Related 159 - 160 - - [place.wisp.fs](/lexicons/place-wisp-fs) – Site manifest lexicon 161 - - Custom domains / DNS verification – covered in separate routing/hosting docs 162 - - [AT Protocol Lexicons](https://atproto.com/specs/lexicon) 163 - 164 - ## Additional commentary 165 - 166 - For a detailed write‑up of the full domain system (subdomains, custom domains, DNS TXT/CNAME flow, Caddy on‑demand TLS, and hosting routing), see 167 - **[How wisp.place maps domains to DIDs](https://nekomimi.leaflet.pub/3m5fy2jkurk2a)**. 168 - 169 - 73 + For a detailed write-up of the full domain system including custom domains, DNS verification, and Caddy on-demand TLS, see [How wisp.place maps domains to DIDs](https://nekomimi.leaflet.pub/3m5fy2jkurk2a).
+51 -174
docs/src/content/docs/lexicons/place-wisp-fs.md
··· 3 3 description: Reference for the place.wisp.fs lexicon 4 4 --- 5 5 6 - **Lexicon Version:** 1 6 + The main lexicon for storing static site manifests. Each record represents a complete website with its directory tree and file blob references. 7 7 8 - ## Overview 8 + **Lexicon version:** 1 9 9 10 - The `place.wisp.fs` lexicon defines the structure for storing static site manifests in AT Protocol repositories. Each record represents a complete website with its directory structure and file references. 10 + ## main (record) 11 11 12 - ## Record Structure 12 + Virtual filesystem manifest for a Wisp site. 13 13 14 - <a name="main"></a> 14 + | Property | Type | Required | Constraints | 15 + | --- | --- | --- | --- | 16 + | `site` | `string` | ✅ | Site name (used as record key) | 17 + | `root` | [`#directory`](#directory) | ✅ | Root directory | 18 + | `fileCount` | `integer` | | Min: 0, Max: 1000 | 19 + | `createdAt` | `string` | ✅ | Format: `datetime` | 15 20 16 - ### `main` (Record) 21 + ## entry 17 22 18 - **Type:** `record` 23 + A named entry in a directory. 19 24 20 - **Description:** Virtual filesystem manifest for a Wisp site 25 + | Property | Type | Required | Constraints | 26 + | --- | --- | --- | --- | 27 + | `name` | `string` | ✅ | Max: 255 chars | 28 + | `node` | Union of [`#file`](#file), [`#directory`](#directory), [`#subfs`](#subfs) | ✅ | | 21 29 22 - **Properties:** 30 + ## file 23 31 24 - | Name | Type | Req'd | Description | Constraints | 25 - | ----------- | --------------------------- | ----- | ------------------------------------ | ------------------ | 26 - | `site` | `string` | ✅ | Site name (used as record key) | | 27 - | `root` | [`#directory`](#directory) | ✅ | Root directory of the site | | 28 - | `fileCount` | `integer` | | Total number of files in the site | Min: 0, Max: 1000 | 29 - | `createdAt` | `string` | ✅ | Timestamp of site creation/update | Format: `datetime` | 32 + | Property | Type | Required | Constraints | 33 + | --- | --- | --- | --- | 34 + | `type` | `string` | ✅ | Const: `"file"` | 35 + | `blob` | `blob` | ✅ | Max size: 1 GB | 36 + | `encoding` | `string` | | Enum: `["gzip"]` | 37 + | `mimeType` | `string` | | Original MIME type before compression | 38 + | `base64` | `boolean` | | True if blob is base64-encoded | 30 39 31 - --- 40 + Files are gzip-compressed before upload and uploaded as `application/octet-stream`. They may also be base64-encoded to bypass content sniffing on legacy reference PDS. The original MIME type is stored in `mimeType`. 32 41 33 - <a name="entry"></a> 42 + ## directory 34 43 35 - ### `entry` 44 + | Property | Type | Required | Constraints | 45 + | --- | --- | --- | --- | 46 + | `type` | `string` | ✅ | Const: `"directory"` | 47 + | `entries` | Array of [`#entry`](#entry) | ✅ | Max: 500 entries | 36 48 37 - **Type:** `object` 49 + ## subfs 38 50 39 - **Description:** Named entry in a directory (file, directory, or subfs) 51 + Reference to a `place.wisp.subfs` record for splitting large directories. 40 52 41 - **Properties:** 42 - 43 - | Name | Type | Req'd | Description | Constraints | 44 - | ------ | ------------------------------------------------------- | ----- | ----------------------------------- | ----------- | 45 - | `name` | `string` | ✅ | File or directory name | Max: 255 chars | 46 - | `node` | Union of [`#file`](#file), [`#directory`](#directory), [`#subfs`](#subfs) | ✅ | The node (file, directory, or subfs reference) | | 47 - 48 - --- 49 - 50 - ## Type Definitions 51 - 52 - <a name="file"></a> 53 - 54 - ### `file` 55 - 56 - **Type:** `object` 57 - 58 - **Description:** Represents a file node in the directory tree 59 - 60 - **Properties:** 61 - 62 - | Name | Type | Req'd | Description | Constraints | 63 - | ---------- | --------- | ----- | ------------------------------------------------------------ | ---------------------- | 64 - | `type` | `string` | ✅ | Node type identifier | Const: `"file"` | 65 - | `blob` | `blob` | ✅ | Content blob reference | Max size: 1000000000 (1GB) | 66 - | `encoding` | `string` | | Content encoding (e.g., gzip for compressed files) | Enum: `["gzip"]` | 67 - | `mimeType` | `string` | | Original MIME type before compression | | 68 - | `base64` | `boolean` | | True if blob content is base64-encoded (bypasses PDS sniffing) | | 53 + | Property | Type | Required | Constraints | 54 + | --- | --- | --- | --- | 55 + | `type` | `string` | ✅ | Const: `"subfs"` | 56 + | `subject` | `string` | ✅ | AT-URI to a `place.wisp.subfs` record | 57 + | `flat` | `boolean` | | Default: `true` | 69 58 70 - **Notes:** 71 - - Files are typically gzip compressed before upload 72 - - Text files (HTML/CSS/JS) are also base64 encoded to prevent PDS content-type sniffing 73 - - The blob is uploaded with MIME type `application/octet-stream` 74 - - Original MIME type is preserved in the `mimeType` field 75 - 76 - --- 77 - 78 - <a name="directory"></a> 79 - 80 - ### `directory` 81 - 82 - **Type:** `object` 83 - 84 - **Description:** Represents a directory node in the file tree 85 - 86 - **Properties:** 59 + When `flat` is true (default), the subfs record's entries are merged directly into the parent directory. When `flat` is false, entries are placed in a subdirectory named after the subfs entry. Used automatically when sites exceed 250 files or 140 KB. 87 60 88 - | Name | Type | Req'd | Description | Constraints | 89 - | --------- | -------------------------- | ----- | ------------------------------ | ----------- | 90 - | `type` | `string` | ✅ | Node type identifier | Const: `"directory"` | 91 - | `entries` | Array of [`#entry`](#entry) | ✅ | Child entries in this directory | Max: 500 entries | 61 + ## Examples 92 62 93 - **Notes:** 94 - - Directories can contain files, subdirectories, or subfs references 95 - - Maximum 500 entries per directory to stay within record size limits 96 - 97 - <a name="subfs"></a> 98 - 99 - ### `subfs` 100 - 101 - **Type:** `object` 102 - 103 - **Description:** Reference to a `place.wisp.subfs` record for splitting large directories 104 - 105 - **Properties:** 106 - 107 - | Name | Type | Req'd | Description | Constraints | 108 - | --------- | -------- | ----- | --------------------------------------------------------------------------- | ---------------- | 109 - | `type` | `string` | ✅ | Node type identifier | Const: `"subfs"` | 110 - | `subject` | `string` | ✅ | AT-URI pointing to a place.wisp.subfs record containing this subtree | Format: `at-uri` | 111 - | `flat` | `boolean` | | Controls merging behavior (default: true) | | 112 - 113 - **Notes:** 114 - - When `flat` is true (default), the subfs record's root entries are **merged (flattened)** into the parent directory 115 - - When `flat` is false, the subfs entries are placed in a subdirectory with the subfs entry's name 116 - - The `flat` property controls whether the subfs acts as a content merge or directory replacement 117 - - Allows splitting large directories across multiple records while optionally maintaining flat or nested structure 118 - - Used automatically when sites exceed 250 files or 140KB manifest size 119 - 120 - --- 121 - 122 - ## Usage Examples 123 - 124 - ### Simple Site 63 + ### Simple site 125 64 126 65 ```json 127 66 { ··· 134 73 "name": "index.html", 135 74 "node": { 136 75 "type": "file", 137 - "blob": { 138 - "$type": "blob", 139 - "ref": { "$link": "bafyreiabc..." }, 140 - "mimeType": "application/octet-stream", 141 - "size": 4521 142 - }, 76 + "blob": { "$type": "blob", "ref": { "$link": "bafyreiabc..." }, "mimeType": "application/octet-stream", "size": 4521 }, 143 77 "encoding": "gzip", 144 78 "mimeType": "text/html", 145 79 "base64": true 146 80 } 147 - }, 148 - { 149 - "name": "style.css", 150 - "node": { 151 - "type": "file", 152 - "blob": { 153 - "$type": "blob", 154 - "ref": { "$link": "bafyreidef..." }, 155 - "mimeType": "application/octet-stream", 156 - "size": 2134 157 - }, 158 - "encoding": "gzip", 159 - "mimeType": "text/css", 160 - "base64": true 161 - } 162 81 } 163 82 ] 164 83 }, 165 - "fileCount": 2, 166 - "createdAt": "2024-01-15T10:30:00.000Z" 167 - } 168 - ``` 169 - 170 - ### Site with Subdirectory 171 - 172 - ```json 173 - { 174 - "$type": "place.wisp.fs", 175 - "site": "portfolio", 176 - "root": { 177 - "type": "directory", 178 - "entries": [ 179 - { 180 - "name": "index.html", 181 - "node": { "type": "file", "blob": {...}, "encoding": "gzip", "mimeType": "text/html", "base64": true } 182 - }, 183 - { 184 - "name": "assets", 185 - "node": { 186 - "type": "directory", 187 - "entries": [ 188 - { 189 - "name": "logo.png", 190 - "node": { "type": "file", "blob": {...}, "encoding": "gzip", "mimeType": "image/png", "base64": false } 191 - } 192 - ] 193 - } 194 - } 195 - ] 196 - }, 197 - "fileCount": 2, 84 + "fileCount": 1, 198 85 "createdAt": "2024-01-15T10:30:00.000Z" 199 86 } 200 87 ``` 201 88 202 - ### Large Site with Subfs 89 + ### Large site with subfs 203 90 204 91 ```json 205 92 { ··· 210 97 "entries": [ 211 98 { 212 99 "name": "index.html", 213 - "node": { "type": "file", "blob": {...}, "encoding": "gzip", "mimeType": "text/html", "base64": true } 100 + "node": { "type": "file", "blob": { ... }, "encoding": "gzip", "mimeType": "text/html", "base64": true } 214 101 }, 215 102 { 216 103 "name": "docs", ··· 253 140 "required": ["type", "blob"], 254 141 "properties": { 255 142 "type": { "type": "string", "const": "file" }, 256 - "blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000000, "description": "Content blob ref" }, 257 - "encoding": { "type": "string", "enum": ["gzip"], "description": "Content encoding (e.g., gzip for compressed files)" }, 258 - "mimeType": { "type": "string", "description": "Original MIME type before compression" }, 259 - "base64": { "type": "boolean", "description": "True if blob content is base64-encoded (used to bypass PDS content sniffing)" } 143 + "blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000000 }, 144 + "encoding": { "type": "string", "enum": ["gzip"] }, 145 + "mimeType": { "type": "string" }, 146 + "base64": { "type": "boolean" } 260 147 } 261 148 }, 262 149 "directory": { ··· 264 151 "required": ["type", "entries"], 265 152 "properties": { 266 153 "type": { "type": "string", "const": "directory" }, 267 - "entries": { 268 - "type": "array", 269 - "maxLength": 500, 270 - "items": { "type": "ref", "ref": "#entry" } 271 - } 154 + "entries": { "type": "array", "maxLength": 500, "items": { "type": "ref", "ref": "#entry" } } 272 155 } 273 156 }, 274 157 "entry": { ··· 284 167 "required": ["type", "subject"], 285 168 "properties": { 286 169 "type": { "type": "string", "const": "subfs" }, 287 - "subject": { "type": "string", "format": "at-uri", "description": "AT-URI pointing to a place.wisp.subfs record containing this subtree." }, 288 - "flat": { "type": "boolean", "description": "If true (default), the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false, the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure." } 170 + "subject": { "type": "string", "format": "at-uri" }, 171 + "flat": { "type": "boolean" } 289 172 } 290 173 } 291 174 } 292 175 } 293 176 ``` 294 - 295 - ## Related 296 - 297 - - [place.wisp.subfs](/lexicons/place-wisp-subfs) - Subtree records for large sites 298 - - [AT Protocol Lexicons](https://atproto.com/specs/lexicon) 299 -
+54 -283
docs/src/content/docs/lexicons/place-wisp-subfs.md
··· 3 3 description: Reference for the place.wisp.subfs lexicon 4 4 --- 5 5 6 - **Lexicon Version:** 1 6 + Subtree records for splitting large sites across multiple AT Protocol records. When a site exceeds 250 files or 140 KB, large directories are extracted into `place.wisp.subfs` records and referenced from the main `place.wisp.fs` manifest. 7 7 8 - ## Overview 8 + **Lexicon version:** 1 9 9 10 - The `place.wisp.subfs` lexicon defines subtree records for splitting large sites across multiple AT Protocol records. When a site exceeds size limits (250+ files or 140KB manifest), large directories are extracted into separate `place.wisp.subfs` records. 10 + ## main (record) 11 11 12 - **Key Feature:** Subfs entries are referenced from `place.wisp.fs` records and can be either **merged (flattened)** into the parent directory or placed as a subdirectory, depending on the `flat` property in the parent `place.wisp.fs` record. 12 + | Property | Type | Required | Constraints | 13 + | --- | --- | --- | --- | 14 + | `root` | [`#directory`](#directory) | ✅ | Root directory of the subtree | 15 + | `fileCount` | `integer` | | Min: 0, Max: 1000 | 16 + | `createdAt` | `string` | ✅ | Format: `datetime` | 13 17 14 - ## Record Structure 18 + ## file 15 19 16 - <a name="main"></a> 20 + | Property | Type | Required | Constraints | 21 + | --- | --- | --- | --- | 22 + | `type` | `string` | ✅ | Const: `"file"` | 23 + | `blob` | `blob` | ✅ | Max size: 1 GB | 24 + | `encoding` | `string` | | Enum: `["gzip"]` | 25 + | `mimeType` | `string` | | Original MIME type | 26 + | `base64` | `boolean` | | True if base64-encoded to bypass content sniffing on legacy reference PDS | 17 27 18 - ### `main` (Record) 28 + ## directory 19 29 20 - **Type:** `record` 30 + | Property | Type | Required | Constraints | 31 + | --- | --- | --- | --- | 32 + | `type` | `string` | ✅ | Const: `"directory"` | 33 + | `entries` | Array of [`#entry`](#entry) | ✅ | Max: 500 entries | 21 34 22 - **Description:** Virtual filesystem subtree referenced by place.wisp.fs records. How this subtree is integrated depends on the `flat` property in the referencing subfs entry. 35 + ## entry 23 36 24 - **Properties:** 37 + | Property | Type | Required | Constraints | 38 + | --- | --- | --- | --- | 39 + | `name` | `string` | ✅ | Max: 255 chars | 40 + | `node` | Union of [`#file`](#file), [`#directory`](#directory), [`#subfs`](#subfs) | ✅ | | 25 41 26 - | Name | Type | Req'd | Description | Constraints | 27 - | ----------- | -------------------------- | ----- | ----------------------------------------- | ------------------ | 28 - | `root` | [`#directory`](#directory) | ✅ | Root directory containing subtree entries | | 29 - | `fileCount` | `integer` | | Number of files in this subtree | Min: 0, Max: 1000 | 30 - | `createdAt` | `string` | ✅ | Timestamp of subtree creation | Format: `datetime` | 42 + ## subfs 31 43 32 - --- 33 - 34 - ## Type Definitions 35 - 36 - <a name="file"></a> 37 - 38 - ### `file` 39 - 40 - **Type:** `object` 41 - 42 - **Description:** Represents a file node in the directory tree 43 - 44 - **Properties:** 45 - 46 - | Name | Type | Req'd | Description | Constraints | 47 - | ---------- | --------- | ----- | ------------------------------------------------------------ | ------------------------- | 48 - | `type` | `string` | ✅ | Node type identifier | Const: `"file"` | 49 - | `blob` | `blob` | ✅ | Content blob reference | Max size: 1000000000 (1GB) | 50 - | `encoding` | `string` | | Content encoding (e.g., gzip for compressed files) | Enum: `["gzip"]` | 51 - | `mimeType` | `string` | | Original MIME type before compression | | 52 - | `base64` | `boolean` | | True if blob content is base64-encoded (bypasses PDS sniffing) | | 44 + Reference to another `place.wisp.subfs` record for nested subtrees. Subfs records can reference other subfs records recursively. 53 45 54 - --- 46 + | Property | Type | Required | Constraints | 47 + | --- | --- | --- | --- | 48 + | `type` | `string` | ✅ | Const: `"subfs"` | 49 + | `subject` | `string` | ✅ | AT-URI to another `place.wisp.subfs` record | 55 50 56 - <a name="directory"></a> 51 + ## How Merging Works 57 52 58 - ### `directory` 53 + The `flat` property on the referencing entry in `place.wisp.fs` controls how subfs entries are integrated. 59 54 60 - **Type:** `object` 55 + **With `flat: true` (default)** — subfs entries are merged directly into the parent directory: 61 56 62 - **Description:** Represents a directory node in the file tree 63 - 64 - **Properties:** 65 - 66 - | Name | Type | Req'd | Description | Constraints | 67 - | --------- | -------------------------- | ----- | ------------------------------ | -------------------- | 68 - | `type` | `string` | ✅ | Node type identifier | Const: `"directory"` | 69 - | `entries` | Array of [`#entry`](#entry) | ✅ | Child entries in this directory | Max: 500 entries | 70 - 71 - --- 72 - 73 - <a name="entry"></a> 74 - 75 - ### `entry` 76 - 77 - **Type:** `object` 78 - 79 - **Description:** Named entry in a directory (file, directory, or nested subfs) 80 - 81 - **Properties:** 82 - 83 - | Name | Type | Req'd | Description | Constraints | 84 - | ------ | ------------------------------------------------------- | ----- | ----------------------------------- | ----------- | 85 - | `name` | `string` | ✅ | File or directory name | Max: 255 chars | 86 - | `node` | Union of [`#file`](#file), [`#directory`](#directory), [`#subfs`](#subfs) | ✅ | The node (file, directory, or subfs reference) | | 87 - 88 - --- 89 - 90 - <a name="subfs"></a> 91 - 92 - ### `subfs` 93 - 94 - **Type:** `object` 95 - 96 - **Description:** Reference to another `place.wisp.subfs` record for nested subtrees. When expanded, entries are merged (flattened) into the parent directory by default, unless the parent `place.wisp.fs` record specifies `flat: false`. 97 - 98 - **Properties:** 99 - 100 - | Name | Type | Req'd | Description | Constraints | 101 - | --------- | -------- | ----- | --------------------------------------------------------------------------- | ---------------- | 102 - | `type` | `string` | ✅ | Node type identifier | Const: `"subfs"` | 103 - | `subject` | `string` | ✅ | AT-URI pointing to another place.wisp.subfs record for nested subtrees | Format: `at-uri` | 104 - 105 - **Notes:** 106 - - Subfs records can reference other subfs records recursively 107 - - When expanded, entries are merged (flattened) into the parent directory by default 108 - - The `flat` property in the parent `place.wisp.fs` record controls integration behavior 109 - - Allows splitting very large directory structures 110 - 111 - --- 112 - 113 - ## How Subfs Merging Works 114 - 115 - ### Before Expansion 116 - 117 - Main record (`place.wisp.fs`): 118 - ```json 119 - { 120 - "root": { 121 - "type": "directory", 122 - "entries": [ 123 - { "name": "index.html", "node": { "type": "file", ... } }, 124 - { "name": "docs", "node": { "type": "subfs", "subject": "at://did:plc:abc/place.wisp.subfs/xyz" } } 125 - ] 126 - } 127 - } 128 57 ``` 58 + Main fs root/ 59 + ├── index.html 60 + └── [subfs ref] ──→ { guide.html, api.html } 129 61 130 - Referenced subfs record (`at://did:plc:abc/place.wisp.subfs/xyz`): 131 - ```json 132 - { 133 - "root": { 134 - "type": "directory", 135 - "entries": [ 136 - { "name": "guide.html", "node": { "type": "file", ... } }, 137 - { "name": "api.html", "node": { "type": "file", ... } } 138 - ] 139 - } 140 - } 62 + After expansion: 63 + ├── index.html 64 + ├── guide.html 65 + └── api.html 141 66 ``` 142 67 143 - ### After Expansion (What Hosting Service Sees) 68 + **With `flat: false`** — subfs entries become a subdirectory: 144 69 145 - **With `flat: true` (default):** 146 - 147 - ```json 148 - { 149 - "root": { 150 - "type": "directory", 151 - "entries": [ 152 - { "name": "index.html", "node": { "type": "file", ... } }, 153 - { "name": "guide.html", "node": { "type": "file", ... } }, 154 - { "name": "api.html", "node": { "type": "file", ... } } 155 - ] 156 - } 157 - } 158 70 ``` 159 - 160 - The subfs entries are merged directly into the parent directory. 161 - 162 - **With `flat: false`:** 163 - 164 - ```json 165 - { 166 - "root": { 167 - "type": "directory", 168 - "entries": [ 169 - { "name": "index.html", "node": { "type": "file", ... } }, 170 - { "name": "docs", "node": { 171 - "type": "directory", 172 - "entries": [ 173 - { "name": "guide.html", "node": { "type": "file", ... } }, 174 - { "name": "api.html", "node": { "type": "file", ... } } 175 - ] 176 - }} 177 - ] 178 - } 179 - } 180 - ``` 181 - 182 - The subfs entries are placed in a subdirectory named "docs". 183 - 184 - --- 185 - 186 - ## Usage Examples 187 - 188 - ### Basic Subfs Record 189 - 190 - ```json 191 - { 192 - "$type": "place.wisp.subfs", 193 - "root": { 194 - "type": "directory", 195 - "entries": [ 196 - { 197 - "name": "chapter1.html", 198 - "node": { 199 - "type": "file", 200 - "blob": { 201 - "$type": "blob", 202 - "ref": { "$link": "bafyreiabc..." }, 203 - "mimeType": "application/octet-stream", 204 - "size": 8421 205 - }, 206 - "encoding": "gzip", 207 - "mimeType": "text/html", 208 - "base64": true 209 - } 210 - }, 211 - { 212 - "name": "chapter2.html", 213 - "node": { 214 - "type": "file", 215 - "blob": { 216 - "$type": "blob", 217 - "ref": { "$link": "bafyreidef..." }, 218 - "mimeType": "application/octet-stream", 219 - "size": 9234 220 - }, 221 - "encoding": "gzip", 222 - "mimeType": "text/html", 223 - "base64": true 224 - } 225 - } 226 - ] 227 - }, 228 - "fileCount": 2, 229 - "createdAt": "2024-01-15T10:30:00.000Z" 230 - } 231 - ``` 232 - 233 - ### Nested Subfs (Recursive) 234 - 235 - A subfs record can reference another subfs record: 236 - 237 - ```json 238 - { 239 - "$type": "place.wisp.subfs", 240 - "root": { 241 - "type": "directory", 242 - "entries": [ 243 - { 244 - "name": "section-a.html", 245 - "node": { "type": "file", "blob": {...}, "encoding": "gzip", "mimeType": "text/html", "base64": true } 246 - }, 247 - { 248 - "name": "subsection", 249 - "node": { 250 - "type": "subfs", 251 - "subject": "at://did:plc:abc123/place.wisp.subfs/nested123" 252 - } 253 - } 254 - ] 255 - }, 256 - "fileCount": 50, 257 - "createdAt": "2024-01-15T10:30:00.000Z" 258 - } 71 + After expansion: 72 + ├── index.html 73 + └── docs/ 74 + ├── guide.html 75 + └── api.html 259 76 ``` 260 77 261 - --- 262 - 263 - ## When Are Subfs Records Created? 264 - 265 - The Wisp CLI and web interface automatically create subfs records when: 266 - 267 - 1. **File count threshold**: Site has 250+ files (keeps main manifest under 200 files) 268 - 2. **Size threshold**: Main manifest exceeds 140KB (PDS limit is 150KB) 269 - 3. **Large directories**: Individual directories with many files 270 - 271 - ### Splitting Algorithm 272 - 273 - The `flat` property in the parent `place.wisp.fs` record controls integration behavior: 274 - - `flat: true` (default): Merge subfs entries directly into parent directory 275 - - `flat: false`: Create subdirectory with the subfs entry's name 276 - 277 - --- 278 - 279 - ## Best Practices 280 - 281 - ### For Hosting Services 282 - 283 - - **Fetch recursively**: Load all subfs records referenced in the tree 284 - - **Merge entries**: Replace subfs nodes with directory nodes containing referenced entries 285 - - **Cache merged tree**: Store the fully expanded tree for serving 286 - - **Update on firehose**: Re-fetch and re-merge when subfs records change 287 - 288 - ### For Upload Tools 289 - 290 - - **Reuse subfs records**: Check existing subfs URIs before creating new ones 291 - - **Clean up old records**: Delete unused subfs records after updates 292 - - **Maintain file paths**: Preserve original directory structure when extracting to subfs 293 - 294 - --- 295 - 296 78 ## Lexicon Source 297 79 298 80 ```json ··· 302 84 "defs": { 303 85 "main": { 304 86 "type": "record", 305 - "description": "Virtual filesystem subtree referenced by place.wisp.fs records. When a subfs entry is expanded, its root entries are merged (flattened) into the parent directory, allowing large directories to be split across multiple records while maintaining a flat structure.", 306 87 "record": { 307 88 "type": "object", 308 89 "required": ["root", "createdAt"], ··· 318 99 "required": ["type", "blob"], 319 100 "properties": { 320 101 "type": { "type": "string", "const": "file" }, 321 - "blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000000, "description": "Content blob ref" }, 322 - "encoding": { "type": "string", "enum": ["gzip"], "description": "Content encoding (e.g., gzip for compressed files)" }, 323 - "mimeType": { "type": "string", "description": "Original MIME type before compression" }, 324 - "base64": { "type": "boolean", "description": "True if blob content is base64-encoded (used to bypass PDS content sniffing)" } 102 + "blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000000 }, 103 + "encoding": { "type": "string", "enum": ["gzip"] }, 104 + "mimeType": { "type": "string" }, 105 + "base64": { "type": "boolean" } 325 106 } 326 107 }, 327 108 "directory": { ··· 329 110 "required": ["type", "entries"], 330 111 "properties": { 331 112 "type": { "type": "string", "const": "directory" }, 332 - "entries": { 333 - "type": "array", 334 - "maxLength": 500, 335 - "items": { "type": "ref", "ref": "#entry" } 336 - } 113 + "entries": { "type": "array", "maxLength": 500, "items": { "type": "ref", "ref": "#entry" } } 337 114 } 338 115 }, 339 116 "entry": { ··· 349 126 "required": ["type", "subject"], 350 127 "properties": { 351 128 "type": { "type": "string", "const": "subfs" }, 352 - "subject": { "type": "string", "format": "at-uri", "description": "AT-URI pointing to another place.wisp.subfs record for nested subtrees. Integration behavior (flat vs nested) is controlled by the flat property in the parent place.wisp.fs record." } 129 + "subject": { "type": "string", "format": "at-uri" } 353 130 } 354 131 } 355 132 } 356 133 } 357 134 ``` 358 - 359 - ## Related 360 - 361 - - [place.wisp.fs](/lexicons/place-wisp-fs) - Main site manifest lexicon 362 - - [AT Protocol Lexicons](https://atproto.com/specs/lexicon) 363 -
+142
docs/src/content/docs/lexicons/place-wisp-wh.md
··· 1 + --- 2 + title: place.wisp.v2.wh 3 + description: Webhook record lexicon for receiving HTTP callbacks on AT Protocol events 4 + --- 5 + 6 + Webhooks let you receive HTTP POST notifications when AT Protocol records are created, updated, or deleted. They're scoped to an AT-URI — you can watch a specific record, an entire collection, or everything from a DID. 7 + 8 + Webhooks are stored as `place.wisp.v2.wh` records in your AT Protocol repository. The webhook service watches the firehose and delivers payloads to your URL. 9 + 10 + ## Creating a Webhook 11 + 12 + Create and manage webhooks from the **Webhooks** tab in the editor, or via the [REST API](#rest-api). 13 + 14 + **Scope** controls what you're watching: 15 + 16 + | Scope AT-URI | Watches | 17 + |---|---| 18 + | `at://did:plc:abc` | All record changes from that DID | 19 + | `at://did:plc:abc/app.bsky.feed.post` | All posts from that DID | 20 + | `at://did:plc:abc/app.bsky.feed.post/rkey` | One specific record | 21 + 22 + Enable **backlinks** to also fire when records in *any* repo reference your DID or collection — useful for watching Bluesky likes, Tangled pull requests, etc directed at you. 23 + 24 + **Events** can be filtered to `create`, `update`, `delete`, or any combination. Omit the filter to receive all three. 25 + 26 + **Secret** — if set, every delivery includes an `X-Webhook-Signature` header for verification. 27 + 28 + ## Payload 29 + 30 + Each delivery is an HTTP POST with `Content-Type: application/json`: 31 + 32 + ```json 33 + { 34 + "id": "550e8400-e29b-41d4-a716-446655440000", 35 + "event": "create", 36 + "did": "did:plc:abc123", 37 + "collection": "app.bsky.feed.post", 38 + "rkey": "3kl2jd9s8f7g", 39 + "cid": "bafyreiabc...", 40 + "record": { ... }, 41 + "timestamp": "2024-01-15T10:30:00.000Z" 42 + } 43 + ``` 44 + 45 + `record` is the full record body and is absent on `delete` events. 46 + 47 + **Headers:** 48 + 49 + ``` 50 + User-Agent: wisp.place-webhook/1.0 51 + X-Webhook-Signature: sha256=<hex> (only if secret is set) 52 + ``` 53 + 54 + ## Verifying Signatures 55 + 56 + If you set a secret, verify the `X-Webhook-Signature` header using HMAC-SHA256: 57 + 58 + ```typescript 59 + import { createHmac, timingSafeEqual } from 'crypto' 60 + 61 + function verifySignature(body: string, secret: string, header: string): boolean { 62 + const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex') 63 + return timingSafeEqual(Buffer.from(header), Buffer.from(expected)) 64 + } 65 + ``` 66 + 67 + Always use a timing-safe comparison. Compute the HMAC over the raw request body before parsing. 68 + 69 + ## Delivery 70 + 71 + The webhook service delivers with a 10 second timeout and retries up to 3 times with exponential backoff on failure. Your endpoint should return a 2xx response quickly — do any heavy processing asynchronously. 72 + 73 + Delivery attempts are logged and visible in the editor under each webhook's event history (last 500 events per webhook). 74 + 75 + ## Record Schema 76 + 77 + Webhooks are stored as `place.wisp.v2.wh` records in your PDS: 78 + 79 + ```json 80 + { 81 + "$type": "place.wisp.v2.wh", 82 + "scope": { 83 + "aturi": "at://did:plc:abc123/app.bsky.feed.post", 84 + "backlinks": false 85 + }, 86 + "url": "https://example.com/webhook", 87 + "events": ["create", "update"], 88 + "secret": "your-hmac-secret", 89 + "enabled": true, 90 + "createdAt": "2024-01-15T10:30:00.000Z" 91 + } 92 + ``` 93 + 94 + ## REST API 95 + 96 + Webhooks can also be managed via the main app API. All routes require the signed `did` cookie. 97 + 98 + ### `GET /api/webhook` 99 + 100 + Lists all webhook records for the authenticated user. 101 + 102 + ### `POST /api/webhook` 103 + 104 + Creates a new webhook. Body matches the `place.wisp.v2.wh` record shape. 105 + 106 + ### `DELETE /api/webhook/:rkey` 107 + 108 + Deletes a webhook by its record key. 109 + 110 + ### `GET /api/webhook/events` 111 + 112 + Returns the last 100 delivery events for the authenticated user. 113 + 114 + ```json 115 + [ 116 + { 117 + "id": "...", 118 + "rkey": "abc123", 119 + "url": "https://example.com/webhook", 120 + "event_kind": "create", 121 + "event_did": "did:plc:...", 122 + "event_collection": "app.bsky.feed.post", 123 + "event_rkey": "3kl2jd9s8f7g", 124 + "status": "ok", 125 + "delivered_at": "2024-01-15T10:30:00.000Z" 126 + } 127 + ] 128 + ``` 129 + 130 + ## Self-Hosting 131 + 132 + The webhook service is a separate Bun process in `apps/webhook-service`. 133 + 134 + ```bash 135 + DATABASE_URL="postgres://user:password@localhost:5432/wisp" 136 + JETSTREAM_URL="wss://jetstream2.us-east.bsky.network/subscribe" 137 + HEALTH_PORT=3003 138 + DELIVERY_TIMEOUT_MS=10000 139 + DELIVERY_MAX_RETRIES=3 140 + REDIS_URL="redis://localhost:6379" 141 + WEBHOOK_EVENTS_CHANNEL="webhook:events" 142 + ```
+36 -96
docs/src/content/docs/monitoring.md
··· 1 1 --- 2 2 title: Monitoring & Metrics 3 - description: Track performance and debug issues with Grafana integration 3 + description: Grafana integration for logs and metrics 4 4 --- 5 5 6 - Wisp.place includes built-in observability with automatic Grafana integration for logs and metrics. Monitor request performance, track errors, and analyze usage patterns across both the main backend and hosting service. 7 - 8 - ## Quick Start 9 - 10 - Set environment variables to enable Grafana export: 6 + Set these environment variables and restart your services. Metrics and logs will flow to Grafana automatically. 11 7 12 8 ```bash 13 9 # Grafana Cloud ··· 17 13 GRAFANA_PROMETHEUS_URL=https://prometheus-prod-xxx.grafana.net/api/prom 18 14 GRAFANA_PROMETHEUS_TOKEN=glc_xxx 19 15 20 - # Self-hosted Grafana 16 + # Self-hosted (basic auth instead of bearer token) 21 17 GRAFANA_LOKI_USERNAME=your-username 22 18 GRAFANA_LOKI_PASSWORD=your-password 23 19 ``` 24 20 25 - Restart services. Metrics and logs now flow to Grafana automatically. 21 + See [Grafana Setup](/guides/grafana-setup) for a step-by-step walkthrough. 26 22 27 - ## Metrics Collected 23 + ## Metrics 28 24 29 - ### HTTP Requests 30 - - `http_requests_total` - Total request count by path, method, status 31 - - `http_request_duration_ms` - Request duration histogram 32 - - `errors_total` - Error count by service 25 + - `http_requests_total` — request count by path, method, and status 26 + - `http_request_duration_ms` — duration histogram (P50/P95/P99 available) 27 + - `errors_total` — error count by service 33 28 34 - ### Performance Stats 35 - - P50, P95, P99 response times 36 - - Requests per minute 37 - - Error rates 38 - - Average duration by endpoint 39 - 40 - ## Log Aggregation 29 + ## Log Labels 41 30 42 - Logs are sent to Loki with automatic categorization: 31 + Logs are tagged by service so you can filter them in Loki: 43 32 44 33 ``` 45 - {job="main-app"} |= "error" # OAuth and upload errors 46 - {job="hosting-service"} |= "cache" # Cache operations 47 - {service="hosting-service", level="warn"} # Warnings only 34 + {job="main-app"} # OAuth, uploads, domain management 35 + {job="hosting-service"} # Firehose, caching, content serving 48 36 ``` 49 37 50 - ## Service Identification 51 - 52 - Each service is tagged separately: 53 - - `main-app` - OAuth, uploads, domain management 54 - - `hosting-service` - Firehose, caching, content serving 55 - 56 - ## Configuration Options 57 - 58 - ### Environment Variables 38 + ## All Options 59 39 60 40 ```bash 61 - # Required 62 - GRAFANA_LOKI_URL # Loki endpoint 63 - GRAFANA_PROMETHEUS_URL # Prometheus endpoint (add /api/prom for OTLP) 41 + GRAFANA_LOKI_URL # Loki push endpoint 42 + GRAFANA_PROMETHEUS_URL # Prometheus remote write endpoint 64 43 65 - # Authentication (use one) 66 - GRAFANA_LOKI_TOKEN # Bearer token (Grafana Cloud) 67 - GRAFANA_LOKI_USERNAME # Basic auth (self-hosted) 44 + GRAFANA_LOKI_TOKEN # Bearer token (Grafana Cloud) 45 + GRAFANA_LOKI_USERNAME # Basic auth (self-hosted) 68 46 GRAFANA_LOKI_PASSWORD 69 47 70 - # Optional 71 - GRAFANA_BATCH_SIZE=100 # Batch size before flush 72 - GRAFANA_FLUSH_INTERVAL=5000 # Flush interval in ms 73 - ``` 74 - 75 - ### Programmatic Setup 76 - 77 - ```typescript 78 - import { initializeGrafanaExporters } from '@wisp/observability' 79 - 80 - initializeGrafanaExporters({ 81 - lokiUrl: 'https://logs.grafana.net', 82 - lokiAuth: { bearerToken: 'token' }, 83 - prometheusUrl: 'https://prometheus.grafana.net/api/prom', 84 - prometheusAuth: { bearerToken: 'token' }, 85 - serviceName: 'my-service', 86 - batchSize: 100, 87 - flushIntervalMs: 5000 88 - }) 48 + GRAFANA_BATCH_SIZE=100 # Entries per flush 49 + GRAFANA_FLUSH_INTERVAL=5000 # Flush interval in ms 89 50 ``` 90 51 91 - ## Grafana Dashboard Queries 52 + ## Dashboard Queries 92 53 93 - ### Request Performance 94 54 ```promql 95 55 # Average response time by endpoint 96 56 avg by (path) ( ··· 98 58 rate(http_request_duration_ms_count[5m]) 99 59 ) 100 60 101 - # Request rate 61 + # Request rate by service 102 62 sum(rate(http_requests_total[1m])) by (service) 103 63 104 64 # Error rate ··· 106 66 sum(rate(http_requests_total[5m])) by (service) 107 67 ``` 108 68 109 - ### Log Analysis 110 69 ```logql 111 - # Recent errors 112 70 {job="main-app"} |= "error" | json 113 - 114 - # Slow requests (>1s) 115 71 {job="hosting-service"} |~ "duration.*[1-9][0-9]{3,}" 116 - 117 - # Failed OAuth attempts 118 - {job="main-app"} |= "OAuth" |= "failed" 119 72 ``` 120 73 121 - ## Troubleshooting 122 - 123 - ### Logs not appearing 124 - - Check `GRAFANA_LOKI_URL` is correct (no trailing `/loki/api/v1/push`) 125 - - Verify authentication token/credentials 126 - - Look for connection errors in service logs 127 - 128 - ### Metrics missing 129 - - Ensure `GRAFANA_PROMETHEUS_URL` includes `/api/prom` suffix 130 - - Check firewall rules allow outbound HTTPS 131 - - Verify OpenTelemetry export errors in logs 74 + ## Without Grafana 132 75 133 - ### High memory usage 134 - - Reduce `GRAFANA_BATCH_SIZE` (default: 100) 135 - - Lower `GRAFANA_FLUSH_INTERVAL` to flush more frequently 136 - 137 - ## Local Development 138 - 139 - Metrics and logs are stored in-memory when Grafana isn't configured. Access them via: 76 + Metrics and logs are always stored in-memory. Access them directly: 140 77 141 78 - `http://localhost:8000/api/observability/logs` 142 79 - `http://localhost:8000/api/observability/metrics` 143 80 - `http://localhost:8000/api/observability/errors` 144 81 145 - ## Testing Integration 82 + ## Programmatic Setup 146 83 147 - Run integration tests to verify setup: 84 + ```typescript 85 + import { initializeGrafanaExporters } from '@wisp/observability' 148 86 149 - ```bash 150 - cd packages/@wisp/observability 151 - bun test src/integration-test.test.ts 152 - 153 - # Test with live Grafana 154 - GRAFANA_LOKI_URL=... GRAFANA_LOKI_USERNAME=... GRAFANA_LOKI_PASSWORD=... \ 155 - bun test src/integration-test.test.ts 156 - ``` 87 + initializeGrafanaExporters({ 88 + lokiUrl: 'https://logs.grafana.net', 89 + lokiAuth: { bearerToken: 'token' }, 90 + prometheusUrl: 'https://prometheus.grafana.net/api/prom', 91 + prometheusAuth: { bearerToken: 'token' }, 92 + serviceName: 'my-service', 93 + batchSize: 100, 94 + flushIntervalMs: 5000 95 + }) 96 + ```
+31 -174
docs/src/content/docs/redirects.md
··· 1 1 --- 2 2 title: Redirects & Rewrites 3 - description: Netlify-style _redirects file support for flexible URL routing 3 + description: Netlify-style _redirects file support 4 4 --- 5 5 6 - # Redirects & Rewrites 7 - 8 - Wisp.place supports Netlify-style `_redirects` files, giving you powerful control over URL routing and redirects. Whether you're migrating an old site, setting up a single-page app, or creating clean URLs, the `_redirects` file lets you handle complex routing scenarios without changing your actual file structure. 9 - 10 - ## Getting Started 11 - 12 - Drop a file named `_redirects` in your site's root directory. Each line defines a redirect rule with the format: 13 - 14 - ``` 15 - /from/path /to/path [status] [conditions] 16 - ``` 6 + Drop a `_redirects` file in your site's root directory. Each line is a rule — processed top to bottom, first match wins. 17 7 18 - For example: 19 8 ``` 20 - /old-page /new-page 21 - /blog/* /posts/:splat 301 22 - ``` 23 - 24 - ## Basic Redirects 25 - 26 - The simplest redirects move traffic from one URL to another: 27 - 28 - ``` 29 - /home / 30 - /about-us /about 31 - /old-blog /blog 9 + /from/path /to/path [status] 32 10 ``` 33 - 34 - These use a permanent redirect (301) by default, telling browsers and search engines the page has moved permanently. 35 11 36 12 ## Status Codes 37 13 38 - You can specify different HTTP status codes to change how the redirect behaves: 14 + `301` is permanent (default), `302` is temporary, `200` serves new content while keeping the original URL, and `404` serves a custom error page. Append `!` to force a rule even when the source path exists as an actual file. 39 15 40 - **301 - Permanent Redirect** 41 16 ``` 42 - /legacy-page /new-page 301 17 + /old-page /new-page 301 18 + /temp-sale /sale-page 302 19 + /api/* /functions/:splat 200 20 + /shop/* /shop-closed.html 404 21 + /file /other 200! 43 22 ``` 44 - Tells browsers and search engines the page has moved permanently. Good for SEO when content has truly moved. 45 23 46 - **302 - Temporary Redirect** 47 - ``` 48 - /temp-sale /sale-page 302 49 - ``` 50 - Indicates a temporary move. Browsers won't cache this as strongly, and search engines won't transfer SEO value. 24 + ## Wildcards & Placeholders 51 25 52 - **200 - Rewrite** 53 - ``` 54 - /api/* /functions/:splat 200 55 - ``` 56 - Serves different content but keeps the original URL visible to users. Perfect for API routing or single-page apps. 26 + `:splat` captures the wildcard match: 57 27 58 - **404 - Custom Error Page** 59 - ``` 60 - /shop/* /shop-closed.html 404 61 - ``` 62 - Shows a custom error page instead of the default 404. Useful for seasonal closures or section-specific error handling. 63 - 64 - **Force with `!`** 65 - ``` 66 - /existing-file /other-file 200! 67 - ``` 68 - Normally, if the original path exists as a file, the redirect won't trigger. Add `!` to force it anyway. 69 - 70 - ## Wildcard Redirects 71 - 72 - Splats (`*`) let you match entire path segments: 73 - 74 - **Simple wildcards:** 75 28 ``` 76 29 /news/* /blog/:splat 77 30 /old-site/* /new-site/:splat 78 31 ``` 79 32 80 - If someone visits `/news/tech-update`, they'll be redirected to `/blog/tech-update`. 81 - 82 - **Multiple wildcards:** 83 - ``` 84 - /products/*/details/* /shop/:splat/info/:splat 85 - ``` 86 - 87 - This captures multiple path segments and maps them to the new structure. 88 - 89 - ## Placeholders 90 - 91 - Placeholders let you restructure URLs with named parameters: 33 + Named placeholders for structured URLs: 92 34 93 35 ``` 94 36 /blog/:year/:month/:day/:slug /posts/:year-:month-:day/:slug 95 37 /products/:category/:id /shop/:category/item/:id 96 38 ``` 97 39 98 - These are more precise than splats because you can reference the captured values by name. Visiting `/blog/2024/01/15/my-post` redirects to `/posts/2024-01-15/my-post`. 99 - 100 - ## Query Parameters 101 - 102 - You can match and redirect based on URL parameters: 40 + Query parameters work too: 103 41 104 42 ``` 105 43 /store?id=:id /products/:id 106 44 /search?q=:query /find/:query 107 45 ``` 108 46 109 - The query parameter becomes part of the redirect path. `/store?id=123` becomes `/products/123`. 110 - 111 47 ## Conditional Redirects 112 48 113 - Make redirects happen only under certain conditions: 49 + Route based on country (ISO 3166-1 alpha-2), browser language, or cookie: 114 50 115 - **Country-based:** 116 51 ``` 117 - / /us/ 302 Country=us 118 - / /uk/ 302 Country=gb 119 - ``` 52 + / /us/ 302 Country=us 53 + / /uk/ 302 Country=gb 120 54 121 - Redirects users based on their country (using ISO 3166-1 alpha-2 codes). 55 + /products /en/products 301 Language=en 56 + /products /de/products 301 Language=de 122 57 123 - **Language-based:** 58 + /* /legacy/:splat 200 Cookie=is_legacy 124 59 ``` 125 - /products /en/products 301 Language=en 126 - /products /de/products 301 Language=de 127 - ``` 128 - 129 - Routes based on browser language preferences. 130 - 131 - **Cookie-based:** 132 - ``` 133 - /* /legacy/:splat 200 Cookie=is_legacy 134 - ``` 135 - 136 - Only redirects if the user has a specific cookie set. 137 - 138 - ## Advanced Patterns 139 - 140 - **Single-page app routing:** 141 - ``` 142 - /* /index.html 200 143 - ``` 144 - 145 - Send all unmatched routes to your main app file. Perfect for React, Vue, or Angular apps. 146 - 147 - **API proxying:** 148 - ``` 149 - /api/* https://api.example.com/:splat 200 150 - ``` 151 - 152 - Proxy API calls to external services while keeping the URL clean. 153 - 154 - **Domain redirects:** 155 - ``` 156 - http://blog.example.com/* https://example.com/blog/:splat 301! 157 - ``` 158 - 159 - Redirect from subdomains or entirely different domains. 160 - 161 - **Extension removal:** 162 - ``` 163 - /page.html /page 164 - ``` 165 - 166 - Clean up old `.html` extensions for a modern look. 167 - 168 - ## How It Works 169 - 170 - 1. **Processing order:** Rules are checked from top to bottom - first match wins 171 - 2. **Specificity:** More specific rules should come before general ones 172 - 3. **Caching:** Redirects are cached for performance but respect the site's cache headers 173 - 4. **Performance:** All processing happens at the edge, close to your users 174 - 175 - ## Examples 176 60 177 - Here's a complete `_redirects` file for a typical site migration: 61 + ## Common Patterns 178 62 179 63 ``` 180 - # Old blog structure to new 181 - /blog/* /posts/:splat 301 64 + # SPA fallback 65 + /* /index.html 200 182 66 183 67 # API proxy 184 - /api/* https://api.example.com/:splat 200 68 + /api/* https://api.example.com/:splat 200 185 69 186 - # Country redirects for homepage 187 - / /us/ 302 Country=us 188 - / /uk/ 302 Country=gb 189 - 190 - # Single-page app fallback 191 - /* /index.html 200 70 + # Remove .html extensions 71 + /page.html /page 192 72 193 - # Custom 404 for shop section 194 - /shop/* /shop/closed.html 404 73 + # Full example 74 + /blog/* /posts/:splat 301 75 + /api/* https://api.example.com/:splat 200 76 + / /us/ 302 Country=us 77 + / /uk/ 302 Country=gb 78 + /* /index.html 200 195 79 ``` 196 - 197 - ## Tips 198 - 199 - - **Order matters:** Put specific rules before general ones 200 - - **Test thoroughly:** Use the preview feature to check your redirects 201 - - **Use 301 for SEO:** Permanent redirects pass SEO value to new pages 202 - - **Use 200 for SPAs:** Rewrites keep your app's routing intact 203 - - **Force when needed:** The `!` flag overrides existing files 204 - - **Keep it simple:** Most sites only need a few redirect rules 205 - 206 - ## Troubleshooting 207 - 208 - **Redirect not working?** 209 - - Check the order - rules are processed top to bottom 210 - - Make sure the file is named exactly `_redirects` (no extension) 211 - - Verify the file is in your site's root directory 212 - 213 - **Wildcard not matching?** 214 - - Wildcards only work at the end of paths 215 - - Use placeholders for more complex restructuring 216 - 217 - **Conditional redirect not triggering?** 218 - - Country detection uses IP geolocation 219 - - Language uses Accept-Language headers 220 - - Cookies must match exactly 221 - 222 - The `_redirects` system gives you the flexibility to handle complex routing scenarios while keeping your site structure clean and maintainable.
-11
docs/src/content/docs/reference/example.md
··· 1 - --- 2 - title: Example Reference 3 - description: A reference page in my new Starlight docs site. 4 - --- 5 - 6 - Reference pages are ideal for outlining how things work in terse and clear terms. 7 - Less concerned with telling a story or addressing a specific use case, they should give a comprehensive outline of what you're documenting. 8 - 9 - ## Further reading 10 - 11 - - Read [about reference](https://diataxis.fr/reference/) in the Diátaxis framework
+43 -89
docs/src/content/docs/reference/main-app-api.md
··· 1 1 --- 2 2 title: Main App API 3 - description: Expected responses from the main-app Elysia routes. 3 + description: REST endpoints served by the main app 4 4 --- 5 5 6 - These endpoints power the main wisp.place backend (Bun + Elysia). Responses below are the shapes returned by the handlers in `apps/main-app/src/routes/*`. 6 + Internal REST API for the main app (Bun + Elysia). Authenticated routes require a signed `did` cookie. Admin routes require a signed `admin_session` cookie and return `401 { error: 'Unauthorized' }` otherwise. 7 + 8 + For the AT Protocol XRPC endpoints, see [XRPC API](/reference/xrpc-api). 7 9 8 - Notes: 9 - - Authenticated routes rely on the signed `did` cookie. If authentication fails, the handler throws and Elysia returns an error response. 10 - - Admin routes rely on the signed `admin_session` cookie. Unauthorized requests return `401 { error: 'Unauthorized' }`. 10 + --- 11 11 12 - ## Auth (`/api/auth/*`) 12 + ## Auth `/api/auth/*` 13 13 14 14 ### `GET /api/auth/login` 15 15 Redirects to the AT Protocol OAuth authorize URL. 16 16 17 17 - **302** → OAuth URL 18 - - **302** → `/?error=missing_handle` if no handle/PDS provided 18 + - **302** → `/?error=missing_handle` if no handle provided 19 19 - **302** → `/?error=auth_failed` on failure 20 20 21 21 ### `POST /api/auth/signin` 22 22 ```json 23 23 { "url": "https://..." } 24 24 ``` 25 - On failure: 26 - ```json 27 - { "error": "Authentication failed", "details": "..." } 28 - ``` 25 + On failure: `{ "error": "Authentication failed", "details": "..." }` 29 26 30 27 ### `GET /api/auth/callback` 31 - Redirects after OAuth completes. 32 - 33 - - **302** → `/onboarding` (no sites or domain) 34 - - **302** → `/editor` (existing user) 28 + - **302** → `/onboarding` (new user) 29 + - **302** → `/editor` (returning user) 35 30 - **302** → `/?error=auth_failed` on failure 36 31 37 32 ### `POST /api/auth/logout` 38 33 ```json 39 34 { "success": true } 40 35 ``` 41 - On failure: 42 - ```json 43 - { "error": "Logout failed" } 44 - ``` 45 36 46 37 ### `GET /api/auth/status` 47 - Authenticated: 48 38 ```json 49 39 { "authenticated": true, "did": "did:plc:..." } 50 - ``` 51 - Not authenticated: 52 - ```json 53 40 { "authenticated": false } 54 41 ``` 55 42 56 - ## User (`/api/user/*`) 43 + --- 44 + 45 + ## User `/api/user/*` 57 46 58 47 ### `GET /api/user/status` 59 48 ```json ··· 73 62 74 63 ### `GET /api/user/sites` 75 64 ```json 76 - { "sites": [/* site rows */] } 65 + { "sites": [ /* site rows */ ] } 77 66 ``` 78 67 79 68 ### `GET /api/user/domains` 80 69 ```json 81 70 { 82 71 "wispDomains": [{ "domain": "name.wisp.place", "rkey": "site-rkey" }], 83 - "customDomains": [/* custom domain rows */] 72 + "customDomains": [ /* custom domain rows */ ] 84 73 } 85 74 ``` 86 75 ··· 91 80 92 81 ### `GET /api/user/site/:rkey/domains` 93 82 ```json 94 - { "rkey": "site-rkey", "domains": [/* domain rows */] } 83 + { "rkey": "site-rkey", "domains": [ /* domain rows */ ] } 95 84 ``` 96 85 97 - ## Domain (`/api/domain/*`) 86 + --- 87 + 88 + ## Domain `/api/domain/*` 98 89 99 90 ### `GET /api/domain/check` 100 91 ```json 101 92 { "available": true, "domain": "name.wisp.place" } 102 - ``` 103 - Invalid handle: 104 - ```json 105 93 { "available": false, "reason": "invalid" } 106 94 ``` 107 95 108 96 ### `GET /api/domain/registered` 109 - Registered: 110 97 ```json 111 98 { "registered": true, "type": "wisp", "domain": "name.wisp.place", "did": "did:plc:...", "rkey": "site-rkey" } 112 - ``` 113 - Custom domain: 114 - ```json 115 99 { "registered": true, "type": "custom", "domain": "example.com", "did": "did:plc:...", "rkey": "site-rkey", "verified": true } 116 - ``` 117 - Unregistered: 118 - ```json 119 100 { "registered": false } 120 - ``` 121 - Missing domain: 122 - ```json 123 - { "error": "Domain parameter required" } 124 101 ``` 125 102 126 103 ### `POST /api/domain/claim` ··· 163 140 { "success": true } 164 141 ``` 165 142 166 - ## Site (`/api/site/*`) 143 + --- 144 + 145 + ## Site `/api/site/*` 167 146 168 147 ### `DELETE /api/site/:rkey` 169 148 ```json 170 149 { "success": true, "message": "Site deleted successfully" } 171 150 ``` 172 - On failure: 173 - ```json 174 - { "success": false, "error": "..." } 175 - ``` 151 + On failure: `{ "success": false, "error": "..." }` 176 152 177 153 ### `GET /api/site/:rkey/settings` 178 - Returns the `place.wisp.settings` record when present, otherwise defaults: 154 + Returns the `place.wisp.settings` record or defaults: 179 155 ```json 180 156 { "indexFiles": ["index.html"], "cleanUrls": false, "directoryListing": false } 181 157 ``` 182 - On failure: 183 - ```json 184 - { "success": false, "error": "..." } 185 - ``` 186 158 187 159 ### `POST /api/site/:rkey/settings` 188 160 ```json 189 161 { "success": true, "uri": "at://...", "cid": "bafy..." } 190 162 ``` 191 - On validation failure: 192 - ```json 193 - { "success": false, "error": "Only one of spaMode, directoryListing, or custom404 can be enabled" } 194 - ``` 163 + On failure: `{ "success": false, "error": "Only one of spaMode, directoryListing, or custom404 can be enabled" }` 195 164 196 - ## Wisp Uploads (`/wisp/*`) 165 + --- 197 166 198 - ### `GET /wisp/upload-progress/:jobId` 199 - Server-sent events stream for upload progress. 200 - 201 - - **event:** `progress` → `{ status, progress, result, error }` 202 - - **event:** `done` → `result` 203 - - **event:** `error` → `{ error }` 204 - 205 - Errors: 206 - ```json 207 - { "error": "Job not found" } 208 - ``` 209 - ```json 210 - { "error": "Unauthorized" } 211 - ``` 167 + ## Uploads `/wisp/*` 212 168 213 169 ### `POST /wisp/upload-files` 214 - Empty upload (no files): 215 - ```json 216 - { "success": true, "uri": "at://...", "cid": "bafy...", "fileCount": 0, "siteName": "my-site" } 217 - ``` 218 - Async upload: 219 170 ```json 220 171 { "success": true, "jobId": "...", "message": "Upload started. Connect to /wisp/upload-progress/..." } 221 172 ``` 173 + Empty upload: `{ "success": true, "uri": "at://...", "cid": "bafy...", "fileCount": 0, "siteName": "my-site" }` 222 174 223 - ## Admin (`/api/admin/*`) 175 + ### `GET /wisp/upload-progress/:jobId` 176 + Server-sent events stream: 177 + 178 + - `progress` → `{ status, progress, result, error }` 179 + - `done` → `result` 180 + - `error` → `{ error }` 181 + 182 + --- 183 + 184 + ## Admin `/api/admin/*` 224 185 225 186 ### `POST /api/admin/login` 226 187 ```json 227 188 { "success": true } 228 189 ``` 229 - Invalid credentials (401): 230 - ```json 231 - { "error": "Invalid credentials" } 232 - ``` 190 + On failure (401): `{ "error": "Invalid credentials" }` 233 191 234 192 ### `POST /api/admin/logout` 235 193 ```json ··· 237 195 ``` 238 196 239 197 ### `GET /api/admin/status` 240 - Authenticated: 241 198 ```json 242 199 { "authenticated": true, "username": "admin" } 243 - ``` 244 - Not authenticated: 245 - ```json 246 200 { "authenticated": false } 247 201 ``` 248 202 249 203 ### `GET /api/admin/logs` 250 204 ```json 251 - { "logs": [/* combined logs */] } 205 + { "logs": [ /* combined log entries */ ] } 252 206 ``` 253 207 254 208 ### `GET /api/admin/errors` 255 209 ```json 256 - { "errors": [/* combined errors */] } 210 + { "errors": [ /* combined error entries */ ] } 257 211 ``` 258 212 259 213 ### `GET /api/admin/metrics` ··· 267 221 ``` 268 222 269 223 ### `GET /api/admin/cache` 270 - Returns the hosting service cache stats payload or: 224 + Returns hosting service cache stats, or: 271 225 ```json 272 226 { "error": "Failed to fetch cache stats from hosting service", "message": "Hosting service unavailable" } 273 227 ``` 274 228 275 229 ### `GET /api/admin/sites` 276 230 ```json 277 - { "sites": [/* sites */], "customDomains": [/* domains */] } 231 + { "sites": [ /* sites */ ], "customDomains": [ /* domains */ ] } 278 232 ``` 279 233 280 234 ### `GET /api/admin/health`
+239
docs/src/content/docs/reference/xrpc-api.md
··· 1 + --- 2 + title: XRPC API 3 + description: AT Protocol XRPC endpoints served by the main app 4 + --- 5 + 6 + The main app serves AT Protocol XRPC endpoints at `/xrpc/{nsid}`. All authenticated endpoints require a service JWT in the `Authorization: Bearer <token>` header, scoped to the called NSID (`lxm` claim). 7 + 8 + Each endpoint also accepts kebab-case and lowercase NSID aliases (e.g. `place.wisp.v2.domain.add-site`, `place.wisp.v2.domain.addsite`). 9 + 10 + --- 11 + 12 + ## Domain 13 + 14 + ### `place.wisp.v2.domain.getStatus` — query 15 + 16 + Returns the registration status of any domain. Auth is optional — if authenticated, also returns ownership info for domains you own. 17 + 18 + **Params:** 19 + 20 + | Field | Type | Required | 21 + |---|---|---| 22 + | `domain` | `string` | ✅ | 23 + 24 + **Response:** 25 + 26 + | Field | Type | 27 + |---|---| 28 + | `domain` | `string` | 29 + | `status` | `"unclaimed" \| "pendingVerification" \| "verified" \| "alreadyClaimed"` | 30 + | `kind` | `"wisp" \| "custom"` | 31 + | `verified` | `boolean` | 32 + | `siteRkey` | `string` | 33 + | `lastCheckedAt` | `string` (datetime) | 34 + | `lastError` | `string` | 35 + 36 + --- 37 + 38 + ### `place.wisp.v2.domain.getList` — query 🔒 39 + 40 + Returns all domains (wisp subdomains and custom domains) owned by the authenticated DID. 41 + 42 + **Response:** 43 + 44 + ```json 45 + { 46 + "domains": [ 47 + { 48 + "domain": "alice.wisp.place", 49 + "kind": "wisp", 50 + "status": "verified", 51 + "verified": true, 52 + "siteRkey": "my-site" 53 + }, 54 + { 55 + "domain": "example.com", 56 + "kind": "custom", 57 + "status": "pendingVerification", 58 + "verified": false, 59 + "lastCheckedAt": "2024-01-15T10:30:00.000Z" 60 + } 61 + ] 62 + } 63 + ``` 64 + 65 + **Errors:** `AuthenticationRequired` 66 + 67 + --- 68 + 69 + ### `place.wisp.v2.domain.claimSubdomain` — procedure 🔒 70 + 71 + Claims a `*.wisp.place` subdomain for the authenticated DID. Max 3 wisp subdomains per DID. 72 + 73 + **Input:** 74 + 75 + | Field | Type | Required | Notes | 76 + |---|---|---|---| 77 + | `handle` | `string` | ✅ | Subdomain label only, e.g. `alice` (3–63 chars, `a-z0-9-`) | 78 + | `siteRkey` | `string` | | Map a site immediately after claim | 79 + 80 + **Response:** 81 + 82 + | Field | Type | 83 + |---|---| 84 + | `domain` | `string` | 85 + | `kind` | `"wisp"` | 86 + | `status` | `"verified" \| "alreadyClaimed"` | 87 + | `siteRkey` | `string` | 88 + 89 + **Errors:** `AuthenticationRequired`, `InvalidDomain`, `AlreadyClaimed`, `DomainLimitReached`, `RateLimitExceeded` 90 + 91 + --- 92 + 93 + ### `place.wisp.v2.domain.claim` — procedure 🔒 94 + 95 + Claims a custom domain for the authenticated DID. Returns DNS challenge details for ownership verification. 96 + 97 + **Input:** 98 + 99 + | Field | Type | Required | Notes | 100 + |---|---|---|---| 101 + | `domain` | `string` | ✅ | Custom FQDN (3–253 chars) | 102 + | `siteRkey` | `string` | | Map a site immediately after claim | 103 + 104 + **Response:** 105 + 106 + | Field | Type | Notes | 107 + |---|---|---| 108 + | `domain` | `string` | | 109 + | `kind` | `"custom"` | | 110 + | `status` | `"alreadyClaimed" \| "pendingVerification" \| "verified"` | | 111 + | `challengeId` | `string` | Used to derive DNS targets | 112 + | `txtName` | `string` | TXT record hostname for ownership proof | 113 + | `txtValue` | `string` | TXT record value (your DID) | 114 + | `cnameTarget` | `string` | Advisory CNAME target | 115 + | `siteRkey` | `string` | | 116 + 117 + **Errors:** `AuthenticationRequired`, `InvalidDomain`, `AlreadyClaimed`, `DomainLimitReached`, `RateLimitExceeded` 118 + 119 + --- 120 + 121 + ### `place.wisp.v2.domain.addSite` — procedure 🔒 122 + 123 + Maps a site to a domain you own. 124 + 125 + **Input:** 126 + 127 + | Field | Type | Required | 128 + |---|---|---| 129 + | `domain` | `string` | ✅ | 130 + | `siteRkey` | `string` | ✅ | 131 + 132 + **Response:** 133 + 134 + | Field | Type | 135 + |---|---| 136 + | `domain` | `string` | 137 + | `kind` | `"wisp" \| "custom"` | 138 + | `status` | `"pendingVerification" \| "verified"` | 139 + | `siteRkey` | `string` | 140 + | `mapped` | `true` | 141 + 142 + **Errors:** `AuthenticationRequired`, `InvalidDomain`, `InvalidRequest`, `NotFound` 143 + 144 + --- 145 + 146 + ### `place.wisp.v2.domain.delete` — procedure 🔒 147 + 148 + Deletes a domain (wisp subdomain or custom domain) owned by the authenticated DID. 149 + 150 + **Params:** 151 + 152 + | Field | Type | Required | 153 + |---|---|---| 154 + | `domain` | `string` | ✅ | 155 + 156 + **Response:** 157 + 158 + ```json 159 + { "domain": "alice.wisp.place", "deleted": true } 160 + ``` 161 + 162 + **Errors:** `AuthenticationRequired`, `InvalidDomain`, `NotFound` 163 + 164 + --- 165 + 166 + ## Site 167 + 168 + ### `place.wisp.v2.site.getList` — query 🔒 169 + 170 + Returns all sites owned by the authenticated DID, with their mapped domains. 171 + 172 + **Response:** 173 + 174 + ```json 175 + { 176 + "sites": [ 177 + { 178 + "siteRkey": "my-site", 179 + "displayName": "My Site", 180 + "createdAt": "2024-01-15T10:30:00.000Z", 181 + "updatedAt": "2024-01-15T10:30:00.000Z", 182 + "domains": [ 183 + { "domain": "alice.wisp.place", "kind": "wisp", "status": "verified", "verified": true } 184 + ] 185 + } 186 + ] 187 + } 188 + ``` 189 + 190 + **Errors:** `AuthenticationRequired` 191 + 192 + --- 193 + 194 + ### `place.wisp.v2.site.getDomains` — query 195 + 196 + Returns all domains mapped to a specific site. Public — no auth required. 197 + 198 + **Params:** 199 + 200 + | Field | Type | Required | 201 + |---|---|---| 202 + | `did` | `string` | ✅ | 203 + | `rkey` | `string` | ✅ | 204 + 205 + **Response:** 206 + 207 + ```json 208 + { 209 + "domains": [ 210 + { "domain": "alice.wisp.place", "kind": "wisp", "status": "verified", "verified": true } 211 + ] 212 + } 213 + ``` 214 + 215 + --- 216 + 217 + ### `place.wisp.v2.site.delete` — procedure 🔒 218 + 219 + Deletes a site and detaches all mapped domains. 220 + 221 + **Input:** 222 + 223 + | Field | Type | Required | 224 + |---|---|---| 225 + | `siteRkey` | `string` | ✅ | 226 + 227 + **Response:** 228 + 229 + ```json 230 + { 231 + "siteRkey": "my-site", 232 + "deleted": true, 233 + "unmappedDomains": [ 234 + { "domain": "alice.wisp.place", "kind": "wisp", "status": "verified" } 235 + ] 236 + } 237 + ``` 238 + 239 + **Errors:** `AuthenticationRequired`, `InvalidRequest`, `NotFound`
+528 -79
docs/src/styles/custom.css
··· 1 - /* Use the same color scheme as the main app */ 1 + /* 2 + * wisp.place docs 3 + * 4 + * Matches the editor dashboard aesthetic: 5 + * Clean, flat, keyboard-first · sharp corners · tight spacing 6 + * JetBrains Mono on chrome/code · warm-beige light / slate-violet dark 7 + */ 8 + 9 + @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap'); 10 + 11 + /* ── Color tokens ──────────────────────────────────────────────────────────── */ 12 + 2 13 :root { 14 + --sl-font-mono: "JetBrains Mono", ui-monospace, monospace; 3 15 --sl-content-width: 70rem; 4 - 5 - /* Increase base font size by 10% */ 6 - font-size: 110%; 16 + --noise-opacity: 0.18; 7 17 8 - /* Light theme - Warm beige with improved contrast */ 9 - --sl-color-bg: oklch(0.92 0.012 35); 10 - --sl-color-bg-sidebar: oklch(0.95 0.008 35); 11 - --sl-color-bg-nav: oklch(0.95 0.008 35); 12 - --sl-color-text: oklch(0.15 0.015 30); 18 + /* Light — warm beige */ 19 + --sl-color-bg: oklch(0.92 0.012 35); 20 + --sl-color-bg-sidebar: oklch(0.88 0.01 35); 21 + --sl-color-bg-nav: oklch(0.88 0.01 35); 22 + --sl-color-text: oklch(0.15 0.015 30); 13 23 --sl-color-text-accent: oklch(0.65 0.18 345); 14 - --sl-color-accent: oklch(0.65 0.18 345); 15 - --sl-color-accent-low: oklch(0.92 0.05 345); 16 - --sl-color-border: oklch(0.65 0.02 30); 17 - --sl-color-gray-1: oklch(0.45 0.02 30); 18 - --sl-color-gray-2: oklch(0.35 0.02 30); 19 - --sl-color-gray-3: oklch(0.28 0.02 30); 20 - --sl-color-gray-4: oklch(0.20 0.015 30); 21 - --sl-color-gray-5: oklch(0.65 0.02 30); 22 - --sl-color-bg-accent: oklch(0.88 0.01 35); 24 + --sl-color-accent: oklch(0.65 0.18 345); 25 + --sl-color-accent-low: oklch(0.90 0.06 345); 26 + --sl-color-accent-high: oklch(0.48 0.22 345); 27 + --sl-color-border: oklch(0.60 0.02 30); 28 + --sl-color-gray-1: oklch(0.42 0.02 30); 29 + --sl-color-gray-2: oklch(0.32 0.02 30); 30 + --sl-color-gray-3: oklch(0.25 0.02 30); 31 + --sl-color-gray-4: oklch(0.18 0.015 30); 32 + --sl-color-gray-5: oklch(0.58 0.02 30); 33 + --sl-color-bg-accent: oklch(0.86 0.012 35); 34 + 35 + /* Terminal code block chrome (light) */ 36 + --term-bg: #f6f8fa; 37 + --term-header: #eaeef2; 38 + --term-border: oklch(0.60 0.02 30); 39 + --term-title: #57606a; 23 40 } 24 41 25 - /* Dark theme - Slate violet background from app */ 42 + /* Dark — slate violet */ 26 43 [data-theme="dark"] { 27 - --sl-color-bg: oklch(0.23 0.015 285); 28 - --sl-color-bg-sidebar: oklch(0.28 0.015 285); 29 - --sl-color-bg-nav: oklch(0.28 0.015 285); 30 - --sl-color-text: oklch(0.90 0.005 285); 44 + --noise-opacity: 0.12; 45 + --sl-color-bg: oklch(0.23 0.015 285); 46 + --sl-color-bg-sidebar: oklch(0.20 0.015 285); 47 + --sl-color-bg-nav: oklch(0.20 0.015 285); 48 + --sl-color-text: oklch(0.90 0.005 285); 31 49 --sl-color-text-accent: oklch(0.85 0.08 5); 32 - --sl-color-accent: oklch(0.85 0.08 5); 33 - --sl-color-accent-low: oklch(0.92 0.05 5); 34 - --sl-color-border: oklch(0.38 0.02 285); 35 - --sl-color-gray-1: oklch(0.82 0.01 285); 36 - --sl-color-gray-2: oklch(0.75 0.01 285); 37 - --sl-color-gray-3: oklch(0.68 0.01 285); 38 - --sl-color-gray-4: oklch(0.60 0.01 285); 39 - --sl-color-gray-5: oklch(0.50 0.02 285); 40 - --sl-color-bg-accent: oklch(0.33 0.015 285); 50 + --sl-color-accent: oklch(0.85 0.08 5); 51 + --sl-color-accent-low: oklch(0.30 0.04 5); 52 + --sl-color-accent-high: oklch(0.92 0.06 5); 53 + --sl-color-border: oklch(0.35 0.02 285); 54 + --sl-color-gray-1: oklch(0.82 0.01 285); 55 + --sl-color-gray-2: oklch(0.75 0.01 285); 56 + --sl-color-gray-3: oklch(0.68 0.01 285); 57 + --sl-color-gray-4: oklch(0.60 0.01 285); 58 + --sl-color-gray-5: oklch(0.40 0.02 285); 59 + --sl-color-bg-accent: oklch(0.28 0.015 285); 60 + 61 + /* Terminal code block chrome (dark) */ 62 + --term-bg: #0d1117; 63 + --term-header: #161b22; 64 + --term-border: oklch(0.32 0.02 285); 65 + --term-title: #8b949e; 41 66 } 42 67 43 - /* Make sidebar narrower */ 68 + /* ── Sidebar width ─────────────────────────────────────────────────────────── */ 69 + 44 70 @media (min-width: 50rem) { 45 - :root { 46 - --sl-sidebar-width: 14rem; 47 - } 71 + :root { --sl-sidebar-width: 16rem; } 72 + } 73 + 74 + /* ── Global ────────────────────────────────────────────────────────────────── */ 75 + 76 + /* Sharp corners */ 77 + *, *::before, *::after { border-radius: 0 !important; } 78 + 79 + /* 80 + * Grain texture — SVG fractal noise as a fixed overlay. 81 + * Gives a subtle paper/matte quality without any trendy pattern. 82 + * pointer-events: none so it never interferes with interaction. 83 + */ 84 + body::after { 85 + content: ""; 86 + position: fixed; 87 + inset: 0; 88 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E"); 89 + background-size: 200px 200px; 90 + opacity: var(--noise-opacity); 91 + mix-blend-mode: multiply; 92 + pointer-events: none; 93 + z-index: 9999; 94 + } 95 + 96 + 97 + 98 + /* Scrollbar */ 99 + ::-webkit-scrollbar { width: 5px; height: 5px; } 100 + ::-webkit-scrollbar-track { background: transparent; } 101 + ::-webkit-scrollbar-thumb { background: var(--sl-color-border); } 102 + ::-webkit-scrollbar-thumb:hover { background: var(--sl-color-gray-5); } 103 + 104 + /* Selection — dimmed so it doesn't overpower the text */ 105 + ::selection { background: color-mix(in oklch, var(--sl-color-accent) 25%, transparent); color: var(--sl-color-text); } 106 + ::-moz-selection { background: color-mix(in oklch, var(--sl-color-accent) 25%, transparent); color: var(--sl-color-text); } 107 + 108 + /* ── Nav bar ───────────────────────────────────────────────────────────────── */ 109 + 110 + header.header { 111 + border-bottom: 1px solid var(--sl-color-border) !important; 112 + background: var(--sl-color-bg-nav) !important; 113 + /* Thin accent stripe across the very top of the page */ 114 + box-shadow: 0 -3px 0 var(--sl-color-accent) inset !important; 115 + } 116 + 117 + a.site-title { 118 + font-family: "JetBrains Mono", ui-monospace, monospace; 119 + font-weight: 700; 120 + font-size: 1rem; 121 + letter-spacing: -0.01em; 122 + color: var(--sl-color-text) !important; 123 + text-decoration: none !important; 124 + } 125 + 126 + a.site-title > :first-child, 127 + a.site-title span:first-child { 128 + color: var(--sl-color-accent) !important; 129 + } 130 + 131 + /* ── Sidebar ───────────────────────────────────────────────────────────────── */ 132 + 133 + .sidebar-pane { 134 + border-right: 1px solid var(--sl-color-border) !important; 135 + background: var(--sl-color-bg-sidebar) !important; 136 + } 137 + 138 + /* Group labels */ 139 + .group-label span, 140 + .top-level > li > details > summary, 141 + summary.large { 142 + font-family: "JetBrains Mono", ui-monospace, monospace !important; 143 + font-size: 0.625rem !important; 144 + font-weight: 700 !important; 145 + text-transform: uppercase !important; 146 + letter-spacing: 0.12em !important; 147 + color: var(--sl-color-gray-2) !important; 148 + padding-top: 0.75rem !important; 149 + padding-bottom: 0.35rem !important; 150 + border-bottom: 1px solid var(--sl-color-border) !important; 151 + display: block !important; 152 + margin-bottom: 0.15rem !important; 153 + } 154 + 155 + /* Sidebar links */ 156 + .sidebar-content a { 157 + font-family: "JetBrains Mono", ui-monospace, monospace !important; 158 + font-size: 0.8rem !important; 159 + padding: 0.28rem 0.75rem !important; 160 + color: var(--sl-color-gray-1) !important; 161 + border-left: 2px solid transparent !important; 162 + text-decoration: none !important; 163 + transform: translateX(0) !important; 164 + transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s !important; 165 + } 166 + 167 + .sidebar-content a:hover { 168 + background: var(--sl-color-bg-accent) !important; 169 + color: var(--sl-color-text) !important; 170 + border-left-color: var(--sl-color-border) !important; 171 + transform: translateX(4px) !important; 172 + text-decoration: none !important; 173 + } 174 + 175 + a[aria-current="page"], 176 + a[aria-current="page"]:hover { 177 + background: var(--sl-color-accent-low) !important; 178 + color: var(--sl-color-accent-high) !important; 179 + border-left: 2px solid var(--sl-color-accent) !important; 180 + font-weight: 600 !important; 181 + transform: translateX(4px) !important; 182 + } 183 + 184 + [data-theme="dark"] a[aria-current="page"], 185 + [data-theme="dark"] a[aria-current="page"]:hover { 186 + background: oklch(0.27 0.018 285) !important; 187 + color: var(--sl-color-accent) !important; 188 + } 189 + 190 + a[aria-current="page"] span, 191 + a[aria-current="page"] .inaccessible { 192 + color: inherit !important; 193 + } 194 + 195 + .sidebar-content ul ul a { 196 + padding-left: 1.5rem !important; 197 + } 198 + 199 + /* ── Content ───────────────────────────────────────────────────────────────── */ 200 + 201 + /* Starlight splits h1 and body into separate .content-panel divs each with 202 + 1.5rem top+bottom padding — halve it so they don't stack into a huge gap */ 203 + .content-panel { 204 + padding-block: 0.75rem !important; 205 + } 206 + 207 + .sl-markdown-content { 208 + line-height: 1.65; 209 + } 210 + 211 + .sl-markdown-content h1 { 212 + font-weight: 700 !important; 213 + font-size: 1.75rem !important; 214 + letter-spacing: -0.02em !important; 215 + border-bottom: 1px solid var(--sl-color-border) !important; 216 + padding-bottom: 0.6rem !important; 217 + margin-bottom: 0.75rem !important; 218 + } 219 + 220 + /* Small accent pip before section headings — inline-block keeps h2/h3 as 221 + block elements so margin collapsing with siblings still works correctly */ 222 + .sl-markdown-content h2 { 223 + font-size: 1.15rem !important; 224 + font-weight: 700 !important; 225 + border-bottom: 1px solid var(--sl-color-border) !important; 226 + padding-bottom: 0.35rem !important; 227 + margin-top: 1.75rem !important; 228 + margin-bottom: 0.65rem !important; 229 + letter-spacing: -0.01em !important; 230 + } 231 + 232 + .sl-markdown-content h2::before { 233 + content: "" !important; 234 + display: inline-block !important; 235 + width: 8px !important; 236 + height: 8px !important; 237 + background: var(--sl-color-accent) !important; 238 + opacity: 0.8 !important; 239 + margin-right: 0.5rem !important; 240 + vertical-align: middle !important; 241 + position: relative !important; 242 + top: -1px !important; 243 + } 244 + 245 + .sl-markdown-content h3 { 246 + font-size: 1rem !important; 247 + font-weight: 600 !important; 248 + margin-top: 1.25rem !important; 249 + margin-bottom: 0.35rem !important; 250 + } 251 + 252 + .sl-markdown-content h3::before { 253 + content: "" !important; 254 + display: inline-block !important; 255 + width: 5px !important; 256 + height: 5px !important; 257 + background: var(--sl-color-accent) !important; 258 + opacity: 0.5 !important; 259 + margin-right: 0.45rem !important; 260 + vertical-align: middle !important; 261 + position: relative !important; 262 + top: -1px !important; 263 + } 264 + 265 + .sl-markdown-content h4 { 266 + font-size: 0.95rem !important; 267 + font-weight: 600 !important; 268 + margin-top: 0.875rem !important; 269 + margin-bottom: 0.25rem !important; 270 + color: var(--sl-color-gray-1) !important; 271 + } 272 + 273 + .sl-markdown-content p { 274 + margin-bottom: 0.75rem !important; 275 + } 276 + 277 + /* Links — animated underline wipe */ 278 + .sl-markdown-content a:not([class]) { 279 + color: var(--sl-color-accent) !important; 280 + text-decoration: none !important; 281 + background-image: linear-gradient(var(--sl-color-accent), var(--sl-color-accent)) !important; 282 + background-size: 0% 1px !important; 283 + background-repeat: no-repeat !important; 284 + background-position: left bottom !important; 285 + transition: background-size 0.2s ease, color 0.15s !important; 286 + } 287 + 288 + .sl-markdown-content a:not([class]):hover { 289 + background-size: 100% 1px !important; 290 + } 291 + 292 + /* Inline code */ 293 + .sl-markdown-content :not(pre) > code { 294 + font-family: "JetBrains Mono", ui-monospace, monospace !important; 295 + font-size: 0.875em !important; 296 + background: var(--sl-color-bg-accent) !important; 297 + border: 1px solid var(--sl-color-border) !important; 298 + padding: 0.1em 0.35em !important; 299 + color: var(--sl-color-text-accent) !important; 300 + } 301 + 302 + /* Lists */ 303 + .sl-markdown-content ul { 304 + margin-bottom: 0.75rem !important; 305 + } 306 + 307 + .sl-markdown-content ul > li, 308 + .sl-markdown-content ol > li { 309 + margin-bottom: 0.2rem !important; 310 + } 311 + 312 + .sl-markdown-content ol { 313 + margin-bottom: 0.75rem !important; 314 + } 315 + 316 + /* Tables */ 317 + .sl-markdown-content table { 318 + width: 100%; 319 + border-collapse: collapse; 320 + font-size: 0.875rem; 321 + margin: 1rem 0; 322 + } 323 + 324 + .sl-markdown-content th { 325 + background: var(--sl-color-bg-accent) !important; 326 + border: 1px solid var(--sl-color-border) !important; 327 + padding: 0.4rem 0.75rem !important; 328 + text-align: left !important; 329 + font-weight: 700 !important; 330 + font-size: 0.7rem !important; 331 + text-transform: uppercase !important; 332 + letter-spacing: 0.08em !important; 333 + color: var(--sl-color-gray-2) !important; 334 + } 335 + 336 + .sl-markdown-content td { 337 + border: 1px solid var(--sl-color-border) !important; 338 + padding: 0.4rem 0.75rem !important; 339 + } 340 + 341 + .sl-markdown-content tr:hover td { 342 + background: var(--sl-color-bg-accent) !important; 343 + } 344 + 345 + /* Blockquotes */ 346 + .sl-markdown-content blockquote { 347 + border-left: 2px solid var(--sl-color-accent) !important; 348 + border-top: none !important; 349 + border-right: none !important; 350 + border-bottom: none !important; 351 + margin: 1rem 0 !important; 352 + padding: 0.5rem 1rem !important; 353 + background: var(--sl-color-bg-accent) !important; 354 + color: var(--sl-color-gray-1) !important; 355 + } 356 + 357 + /* Horizontal rule */ 358 + .sl-markdown-content hr { 359 + border: none !important; 360 + border-top: 1px solid var(--sl-color-border) !important; 361 + margin: 1.5rem 0 !important; 362 + } 363 + 364 + .sl-markdown-content strong { 365 + color: var(--sl-color-text) !important; 366 + font-weight: 700 !important; 367 + } 368 + 369 + /* ── Code blocks ───────────────────────────────────────────────────────────── */ 370 + 371 + .expressive-code { 372 + margin: 1rem 0 !important; 373 + } 374 + 375 + .expressive-code .frame { 376 + border: 1px solid var(--term-border) !important; 377 + overflow: hidden !important; 378 + background: var(--term-bg) !important; 379 + } 380 + 381 + /* Non-terminal frames — hide the empty header entirely */ 382 + .expressive-code .frame:not(.is-terminal) .header { 383 + display: none !important; 384 + } 385 + 386 + /* Terminal frames only — titlebar with traffic-light dots */ 387 + .expressive-code .frame.is-terminal .header { 388 + background: var(--term-header) !important; 389 + border-bottom: 1px solid var(--term-border) !important; 390 + padding: 0 1rem 0 3.5rem !important; 391 + min-height: 1.875rem !important; 392 + display: flex !important; 393 + align-items: center !important; 394 + position: relative !important; 395 + } 396 + 397 + .expressive-code .frame.is-terminal .header::before { 398 + content: "" !important; 399 + position: absolute !important; 400 + left: 12px !important; 401 + top: 50% !important; 402 + transform: translateY(-50%) !important; 403 + width: 40px !important; 404 + height: 12px !important; 405 + /* All three dots drawn as a single background — no border-radius or box-shadow needed */ 406 + background-image: 407 + radial-gradient(circle at 5px 6px, #ff5f56 5px, transparent 5px), 408 + radial-gradient(circle at 20px 6px, #ffbd2e 5px, transparent 5px), 409 + radial-gradient(circle at 35px 6px, #27ca40 5px, transparent 5px) !important; 410 + background-repeat: no-repeat !important; 411 + transition: filter 0.2s !important; 412 + } 413 + 414 + .expressive-code .frame.is-terminal:hover .header::before { 415 + filter: brightness(1.2) drop-shadow(0 0 3px #ff5f5688) !important; 416 + } 417 + 418 + /* Named file title */ 419 + .expressive-code .title { 420 + font-size: 0.75rem !important; 421 + color: var(--term-title) !important; 422 + font-family: "JetBrains Mono", ui-monospace, monospace !important; 423 + } 424 + 425 + .expressive-code pre { 426 + background: var(--term-bg) !important; 427 + margin: 0 !important; 428 + padding: 0.75rem 1.25rem !important; 429 + font-size: 0.875rem !important; 430 + line-height: 1.65 !important; 431 + } 432 + 433 + .expressive-code code { 434 + font-family: "JetBrains Mono", ui-monospace, monospace !important; 435 + font-size: inherit !important; 48 436 } 49 437 50 - /* Increase main grid layout to give more space to content */ 51 - @media (min-width: 50rem) { 52 - .main-wrapper { 53 - display: grid; 54 - grid-template-columns: 1fr; 55 - gap: 0; 56 - } 438 + .expressive-code .copy { 439 + opacity: 0.4 !important; 440 + transition: opacity 0.15s !important; 57 441 } 58 442 59 - /* Text selection styling */ 60 - ::selection { 61 - background: var(--sl-color-accent); 62 - color: white; 443 + .expressive-code .frame:hover .copy { 444 + opacity: 0.8 !important; 63 445 } 64 446 65 - ::-moz-selection { 66 - background: var(--sl-color-accent); 67 - color: white; 447 + /* ── Callouts ──────────────────────────────────────────────────────────────── */ 448 + 449 + aside.starlight-aside, 450 + .starlight-aside { 451 + border: 1px solid var(--sl-color-border) !important; 452 + border-left: 3px solid var(--sl-color-accent) !important; 453 + background: var(--sl-color-bg-accent) !important; 454 + padding: 0.6rem 1rem !important; 455 + margin: 1rem 0 !important; 68 456 } 69 457 70 - /* Sidebar active/hover state text contrast fix */ 71 - .sidebar a[aria-current="page"], 72 - .sidebar a[aria-current="page"] span { 73 - color: oklch(0.15 0.015 30) !important; 458 + .starlight-aside--note { border-left-color: oklch(0.60 0.15 240) !important; } 459 + .starlight-aside--tip { border-left-color: var(--sl-color-accent) !important; } 460 + .starlight-aside--caution { border-left-color: oklch(0.68 0.16 85) !important; } 461 + .starlight-aside--danger { border-left-color: oklch(0.55 0.22 25) !important; } 462 + 463 + .starlight-aside .starlight-aside__title { 464 + font-size: 0.65rem !important; 465 + font-weight: 700 !important; 466 + text-transform: uppercase !important; 467 + letter-spacing: 0.1em !important; 468 + margin-bottom: 0.35rem !important; 469 + } 470 + 471 + /* ── Right sidebar / TOC ───────────────────────────────────────────────────── */ 472 + 473 + .right-sidebar { 474 + border-left: 1px solid var(--sl-color-border) !important; 475 + } 476 + 477 + .right-sidebar h2::before { 478 + display: none !important; 479 + } 480 + 481 + starlight-toc a { 482 + font-size: 0.78rem !important; 483 + border-left: 2px solid transparent !important; 484 + padding-left: 0.5rem !important; 485 + transition: color 0.1s, border-color 0.1s !important; 486 + text-decoration: none !important; 487 + } 488 + 489 + starlight-toc a:hover { 490 + color: var(--sl-color-accent) !important; 491 + border-left-color: var(--sl-color-border) !important; 492 + } 493 + 494 + starlight-toc a[aria-current="true"] { 495 + color: var(--sl-color-accent) !important; 496 + border-left-color: var(--sl-color-accent) !important; 497 + font-weight: 600 !important; 498 + } 499 + 500 + /* ── Badges ────────────────────────────────────────────────────────────────── */ 501 + 502 + .sl-badge { 503 + font-family: "JetBrains Mono", ui-monospace, monospace !important; 504 + font-size: 0.6rem !important; 505 + font-weight: 700 !important; 506 + text-transform: uppercase !important; 507 + letter-spacing: 0.08em !important; 74 508 } 75 509 76 - [data-theme="dark"] .sidebar a[aria-current="page"], 77 - [data-theme="dark"] .sidebar a[aria-current="page"] span { 78 - color: oklch(0.98 0.01 285) !important; 510 + /* ── Pagination ────────────────────────────────────────────────────────────── */ 511 + 512 + .pagination-links { 513 + border-top: 1px solid var(--sl-color-border) !important; 514 + padding-top: 1.25rem !important; 515 + margin-top: 1.5rem !important; 79 516 } 80 517 81 - /* CLI download links styling */ 82 - .downloads { 83 - margin: 2rem 0; 518 + .pagination-links a { 519 + border: 1px solid var(--sl-color-border) !important; 520 + padding: 0.45rem 0.75rem !important; 521 + font-size: 0.825rem !important; 522 + color: var(--sl-color-text) !important; 523 + text-decoration: none !important; 524 + transition: background 0.1s, border-color 0.1s !important; 525 + display: inline-flex !important; 526 + align-items: center !important; 527 + gap: 0.4rem !important; 84 528 } 85 529 86 - .downloads h2 { 87 - margin-bottom: 1rem; 88 - font-size: 1.5rem; 530 + .pagination-links a:hover { 531 + background: var(--sl-color-bg-accent) !important; 532 + border-color: var(--sl-color-accent) !important; 89 533 } 90 534 535 + /* ── Download links (CLI page) ─────────────────────────────────────────────── */ 536 + 537 + .downloads { margin: 1.5rem 0; } 538 + 91 539 .download-link { 92 - display: block; 93 - padding: 0.75rem 1rem; 94 - margin-bottom: 0.5rem; 95 - background: var(--sl-color-bg-nav); 96 - border: 1px solid var(--sl-color-gray-5); 97 - border-radius: 0.5rem; 98 - text-decoration: none; 99 - color: var(--sl-color-text); 100 - transition: all 0.2s ease; 540 + display: flex !important; 541 + align-items: center !important; 542 + gap: 0.5rem !important; 543 + padding: 0.55rem 1rem !important; 544 + margin-bottom: 0.35rem !important; 545 + background: var(--sl-color-bg-accent) !important; 546 + border: 1px solid var(--sl-color-border) !important; 547 + text-decoration: none !important; 548 + color: var(--sl-color-text) !important; 549 + font-size: 0.875rem !important; 550 + transition: background 0.1s, border-color 0.1s !important; 101 551 } 102 552 103 553 .download-link:hover { 104 - background: var(--sl-color-bg-accent); 105 - border-color: var(--sl-color-accent); 106 - text-decoration: none; 554 + background: var(--sl-color-bg) !important; 555 + border-color: var(--sl-color-accent) !important; 556 + text-decoration: none !important; 107 557 } 108 558 109 559 .platform { 110 - font-weight: 600; 111 - color: var(--sl-color-accent); 112 - margin-right: 0.5rem; 560 + font-weight: 600 !important; 561 + color: var(--sl-color-accent) !important; 562 + margin-right: 0.4rem !important; 113 563 } 114 -