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.

biome linting

+208 -151
+13 -5
apps/firehose-service/src/index.ts
··· 22 22 import { fetchSiteRecord, handleSiteCreateOrUpdate, listSiteRecordsForDid } from './lib/cache-writer' 23 23 import { closeDatabase, getSiteCache, listAllKnownDids, listAllSiteCaches, listAllSites, upsertSite } from './lib/db' 24 24 import { getCurrentSeq, getFirehoseHealth, startFirehose, stopFirehose } from './lib/firehose' 25 - import { closeLeaderRedis, getLeaderInfo, readCursor, runLeaderElection, saveCursor } from './lib/leader' 25 + import { closeLeaderRedis, getLeaderInfo, runLeaderElection, saveCursor } from './lib/leader' 26 26 import { startRevalidateWorker, stopRevalidateWorker } from './lib/revalidate-worker' 27 27 import { storage } from './lib/storage' 28 28 ··· 125 125 logger.error(`[Backfill:sites] Failed to list records for DID ${did}`, err) 126 126 didsFailed++ 127 127 } 128 - logger.info(`[Backfill:sites] Progress ${didsProcessed + didsFailed}/${dids.length} DIDs (${sitesSynced} sites synced, ${sitesFailed} sites failed)`) 128 + logger.info( 129 + `[Backfill:sites] Progress ${didsProcessed + didsFailed}/${dids.length} DIDs (${sitesSynced} sites synced, ${sitesFailed} sites failed)`, 130 + ) 129 131 } 130 132 131 133 const inFlight = new Set<Promise<void>>() 132 134 for (const did of dids) { 133 - const task = processDid(did).then(() => { inFlight.delete(task) }) 135 + const task = processDid(did).then(() => { 136 + inFlight.delete(task) 137 + }) 134 138 inFlight.add(task) 135 139 if (inFlight.size >= concurrency) { 136 140 await Promise.race(inFlight) ··· 210 214 failed++ 211 215 } 212 216 213 - logger.info(`Progress: ${processed + skipped + failed}/${sites.length} (${processed} processed, ${skipped} skipped, ${failed} failed)`) 217 + logger.info( 218 + `Progress: ${processed + skipped + failed}/${sites.length} (${processed} processed, ${skipped} skipped, ${failed} failed)`, 219 + ) 214 220 } 215 221 216 222 // Sliding window: keep `concurrency` tasks in flight at all times 217 223 const inFlight = new Set<Promise<void>>() 218 224 for (const site of sites) { 219 - const task = processSite(site).then(() => { inFlight.delete(task) }) 225 + const task = processSite(site).then(() => { 226 + inFlight.delete(task) 227 + }) 220 228 inFlight.add(task) 221 229 if (inFlight.size >= concurrency) { 222 230 await Promise.race(inFlight)
+2 -7
apps/firehose-service/src/lib/html-rewriter.test.ts
··· 198 198 }) 199 199 200 200 test('url() inside <style> text is not rewritten', () => { 201 - const html = '<style>.hero { background: url(\'/images/hero.jpg\') }</style>' 201 + const html = "<style>.hero { background: url('/images/hero.jpg') }</style>" 202 202 expect(rewrite(html)).toBe(html) 203 203 }) 204 204 }) ··· 226 226 }) 227 227 }) 228 228 229 - 230 229 describe('URL features preserved', () => { 231 230 test('query string', () => { 232 231 expect(rewrite('<img src="/img.png?v=3">')).toBe('<img src="/did:plc:abc123/mysite/img.png?v=3">') 233 232 }) 234 233 235 234 test('hash fragment on a path URL', () => { 236 - expect(rewrite('<a href="/page#section">Link</a>')).toBe( 237 - '<a href="/did:plc:abc123/mysite/page#section">Link</a>', 238 - ) 235 + expect(rewrite('<a href="/page#section">Link</a>')).toBe('<a href="/did:plc:abc123/mysite/page#section">Link</a>') 239 236 }) 240 237 241 238 test('query string and hash fragment together', () => { ··· 244 241 ) 245 242 }) 246 243 }) 247 - 248 244 249 245 describe('basePath normalisation', () => { 250 246 test('basePath without trailing slash is normalised', () => { ··· 257 253 expect(result).toBe('<img src="/did:plc:abc123/mysite/img.png">') 258 254 }) 259 255 }) 260 - 261 256 262 257 describe('real-world scenarios', () => { 263 258 test('Vite SPA with already-prefixed paths not double-rewritten', () => {
+4 -1
apps/firehose-service/src/lib/html-rewriter.ts
··· 77 77 for (const [attr, type] of Object.entries(REWRITABLE_ATTRS)) { 78 78 const value = el.getAttribute(attr) 79 79 if (value == null) continue 80 - el.setAttribute(attr, type === 'srcset' ? rewriteSrcset(value, normalizedBase) : rewriteUrl(value, normalizedBase)) 80 + el.setAttribute( 81 + attr, 82 + type === 'srcset' ? rewriteSrcset(value, normalizedBase) : rewriteUrl(value, normalizedBase), 83 + ) 81 84 } 82 85 } 83 86
+5 -3
apps/firehose-service/src/lib/leader.ts
··· 8 8 * Enable with LEADER_ELECTION=true. Requires REDIS_URL. 9 9 */ 10 10 11 + import { randomUUID } from 'node:crypto' 11 12 import { createLogger } from '@wispplace/observability' 12 13 import Redis from 'ioredis' 13 - import { randomUUID } from 'node:crypto' 14 14 import { config } from '../config' 15 15 16 16 const logger = createLogger('firehose-service') ··· 51 51 } 52 52 53 53 async function renewLeadership(): Promise<boolean> { 54 - const result = (await getRedis().eval(RENEW_SCRIPT, 1, LEADER_KEY, instanceId, String(config.leaderTtlMs))) as string | null 54 + const result = (await getRedis().eval(RENEW_SCRIPT, 1, LEADER_KEY, instanceId, String(config.leaderTtlMs))) as 55 + | string 56 + | null 55 57 return result === 'OK' 56 58 } 57 59 ··· 68 70 const val = await getRedis().get(CURSOR_KEY) 69 71 if (!val) return undefined 70 72 const n = parseInt(val, 10) 71 - return isNaN(n) ? undefined : n 73 + return Number.isNaN(n) ? undefined : n 72 74 } catch (err) { 73 75 logger.warn('[Leader] Failed to read cursor', { error: String(err) }) 74 76 return undefined
+1
apps/hosting-service/build.ts
··· 1 1 #!/usr/bin/env bun 2 + export {} 2 3 3 4 const result = await Bun.build({ 4 5 entrypoints: ['./src/index.ts'],
+17 -14
apps/main-app/public/editor/editor.tsx
··· 16 16 import { Tabs, TabsContent, TabsList, TabsTrigger } from '@public/components/ui/tabs' 17 17 import Layout from '@public/layouts' 18 18 import { Loader2, LogOut, Trash2 } from 'lucide-react' 19 - import { useEffect, useState } from 'react' 19 + import { type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent, useEffect, useState } from 'react' 20 20 import { createRoot } from 'react-dom/client' 21 21 import { BrowserRouter, Route, Routes } from 'react-router-dom' 22 22 import { useDomainData } from './hooks/useDomainData' ··· 568 568 </footer> 569 569 570 570 {/* Site Configuration Modal */} 571 - <Dialog open={configuringSite !== null} onOpenChange={(open) => !open && setConfiguringSite(null)}> 571 + <Dialog open={configuringSite !== null} onOpenChange={(open: boolean) => !open && setConfiguringSite(null)}> 572 572 <DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto"> 573 573 <DialogHeader> 574 574 <DialogTitle>Configure Site</DialogTitle> ··· 611 611 <Checkbox 612 612 id={domainId} 613 613 checked={selectedDomains.has(domainId)} 614 - onCheckedChange={(checked) => { 614 + onCheckedChange={(checked: boolean | 'indeterminate') => { 615 615 const newSelected = new Set(selectedDomains) 616 616 if (checked) { 617 617 newSelected.add(domainId) ··· 643 643 <Checkbox 644 644 id={domain.id} 645 645 checked={selectedDomains.has(domain.id)} 646 - onCheckedChange={(checked) => { 646 + onCheckedChange={(checked: boolean | 'indeterminate') => { 647 647 const newSelected = new Set(selectedDomains) 648 648 if (checked) { 649 649 newSelected.add(domain.id) ··· 685 685 {/* Routing Mode */} 686 686 <div className="space-y-3"> 687 687 <Label className="text-sm font-medium">Routing Mode</Label> 688 - <RadioGroup value={routingMode} onValueChange={(value) => setRoutingMode(value as RoutingMode)}> 688 + <RadioGroup 689 + value={routingMode} 690 + onValueChange={(value: string) => setRoutingMode(value as RoutingMode)} 691 + > 689 692 <div className="flex items-center space-x-3 p-3 border rounded-lg"> 690 693 <RadioGroupItem value="default" id="mode-default" /> 691 694 <Label htmlFor="mode-default" className="flex-1 cursor-pointer"> ··· 712 715 <Input 713 716 id="spa-file" 714 717 value={spaFile} 715 - onChange={(e) => setSpaFile(e.target.value)} 718 + onChange={(e: ChangeEvent<HTMLInputElement>) => setSpaFile(e.target.value)} 716 719 placeholder="index.html" 717 720 /> 718 721 </div> ··· 743 746 <Input 744 747 id="404-file" 745 748 value={custom404File} 746 - onChange={(e) => setCustom404File(e.target.value)} 749 + onChange={(e: ChangeEvent<HTMLInputElement>) => setCustom404File(e.target.value)} 747 750 placeholder="404.html" 748 751 /> 749 752 </div> ··· 763 766 <div key={file || idx} className="flex items-center gap-2"> 764 767 <Input 765 768 value={file} 766 - onChange={(e) => { 769 + onChange={(e: ChangeEvent<HTMLInputElement>) => { 767 770 const newFiles = [...indexFiles] 768 771 newFiles[idx] = e.target.value 769 772 setIndexFiles(newFiles) ··· 787 790 <div className="flex items-center gap-2"> 788 791 <Input 789 792 value={newIndexFile} 790 - onChange={(e) => setNewIndexFile(e.target.value)} 793 + onChange={(e: ChangeEvent<HTMLInputElement>) => setNewIndexFile(e.target.value)} 791 794 placeholder="Add index file..." 792 - onKeyDown={(e) => { 795 + onKeyDown={(e: ReactKeyboardEvent<HTMLInputElement>) => { 793 796 if (e.key === 'Enter' && newIndexFile.trim()) { 794 797 setIndexFiles([...indexFiles, newIndexFile.trim()]) 795 798 setNewIndexFile('') ··· 820 823 <Checkbox 821 824 id="clean-urls" 822 825 checked={cleanUrls} 823 - onCheckedChange={(checked) => setCleanUrls(!!checked)} 826 + onCheckedChange={(checked: boolean | 'indeterminate') => setCleanUrls(!!checked)} 824 827 /> 825 828 <Label htmlFor="clean-urls" className="flex-1 cursor-pointer"> 826 829 <div> ··· 838 841 <Checkbox 839 842 id="cors-enabled" 840 843 checked={corsEnabled} 841 - onCheckedChange={(checked) => setCorsEnabled(!!checked)} 844 + onCheckedChange={(checked: boolean | 'indeterminate') => setCorsEnabled(!!checked)} 842 845 /> 843 846 <Label htmlFor="cors-enabled" className="flex-1 cursor-pointer"> 844 847 <div> ··· 855 858 <Input 856 859 id="cors-origin" 857 860 value={corsOrigin} 858 - onChange={(e) => setCorsOrigin(e.target.value)} 861 + onChange={(e: ChangeEvent<HTMLInputElement>) => setCorsOrigin(e.target.value)} 859 862 placeholder="*" 860 863 /> 861 864 <p className="text-xs text-muted-foreground"> ··· 916 919 </Dialog> 917 920 918 921 {/* Delete Site Confirmation Modal */} 919 - <Dialog open={deleteConfirmSite !== null} onOpenChange={(open) => !open && setDeleteConfirmSite(null)}> 922 + <Dialog open={deleteConfirmSite !== null} onOpenChange={(open: boolean) => !open && setDeleteConfirmSite(null)}> 920 923 <DialogContent className="sm:max-w-md"> 921 924 <DialogHeader> 922 925 <DialogTitle>Delete Site</DialogTitle>
+6 -6
apps/main-app/public/editor/tabs/DomainsTab.tsx
··· 12 12 import { Label } from '@public/components/ui/label' 13 13 import { SkeletonShimmer } from '@public/components/ui/skeleton' 14 14 import { AlertCircle, CheckCircle2, Loader2, Trash2, XCircle } from 'lucide-react' 15 - import { useEffect, useRef, useState } from 'react' 15 + import { type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent, useEffect, useRef, useState } from 'react' 16 16 import type { CustomDomain, WispDomain } from '../hooks/useDomainData' 17 17 import type { UserInfo } from '../hooks/useUserInfo' 18 18 ··· 360 360 id="wisp-handle" 361 361 placeholder="mysite" 362 362 value={wispHandle} 363 - onChange={(e) => { 363 + onChange={(e: ChangeEvent<HTMLInputElement>) => { 364 364 setWispHandle(e.target.value) 365 365 if (e.target.value.trim()) checkWispAvailability(e.target.value) 366 366 else setWispAvailability({ available: null, checking: false }) 367 367 }} 368 - onKeyDown={(e) => { 368 + onKeyDown={(e: ReactKeyboardEvent<HTMLInputElement>) => { 369 369 if (e.key === 'Enter') handleClaimWispDomain() 370 370 }} 371 371 disabled={isClaimingWisp} ··· 543 543 id="new-domain" 544 544 placeholder="example.com" 545 545 value={customDomain} 546 - onChange={(e) => setCustomDomain(e.target.value)} 547 - onKeyDown={(e) => { 546 + onChange={(e: ChangeEvent<HTMLInputElement>) => setCustomDomain(e.target.value)} 547 + onKeyDown={(e: ReactKeyboardEvent<HTMLInputElement>) => { 548 548 if (e.key === 'Enter') handleAddCustomDomain() 549 549 }} 550 550 /> ··· 584 584 </Dialog> 585 585 586 586 {/* View DNS Records Modal */} 587 - <Dialog open={viewDomainDNS !== null} onOpenChange={(open) => !open && setViewDomainDNS(null)}> 587 + <Dialog open={viewDomainDNS !== null} onOpenChange={(open: boolean) => !open && setViewDomainDNS(null)}> 588 588 <DialogContent className="sm:max-w-lg max-h-[80vh] overflow-hidden"> 589 589 <DialogHeader> 590 590 <DialogTitle>DNS Configuration</DialogTitle>
+3 -3
apps/main-app/public/editor/tabs/UploadTab.tsx
··· 2 2 import { Input } from '@public/components/ui/input' 3 3 import { Label } from '@public/components/ui/label' 4 4 import { AlertCircle, CheckCircle2, ChevronDown, ChevronUp, Loader2, RefreshCw, Upload, XCircle } from 'lucide-react' 5 - import { useEffect, useRef, useState } from 'react' 5 + import { type ChangeEvent, useEffect, useRef, useState } from 'react' 6 6 import type { SiteWithDomains } from '../hooks/useSiteData' 7 7 8 8 type FileStatus = 'pending' | 'checking' | 'uploading' | 'uploaded' | 'reused' | 'failed' ··· 58 58 } 59 59 }, []) 60 60 61 - const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { 61 + const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => { 62 62 if (e.target.files && e.target.files.length > 0) { 63 63 setSelectedFiles(e.target.files) 64 64 } ··· 489 489 id="new-site-name" 490 490 placeholder="my-awesome-site" 491 491 value={newSiteName} 492 - onChange={(e) => setNewSiteName(e.target.value)} 492 + onChange={(e: ChangeEvent<HTMLInputElement>) => setNewSiteName(e.target.value)} 493 493 disabled={isUploading} 494 494 className="h-9" 495 495 />
+60 -50
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, ChevronDown, ChevronUp, ExternalLink, Loader2, Plus, RefreshCw, Trash2, Webhook } from 'lucide-react' 8 - import { useEffect, useRef, useState } from 'react' 7 + import { CheckCircle2, ChevronUp, ExternalLink, Loader2, Plus, RefreshCw, Trash2, Webhook } from 'lucide-react' 8 + import { type ChangeEvent, useCallback, useEffect, useRef, useState } from 'react' 9 9 import type { WebhookEventLog, WebhookRecord } from '../hooks/useWebhookData' 10 10 11 11 const APPS = [ ··· 116 116 } 117 117 }, [webhooks.length, focusedWebhook]) 118 118 119 + const handleDelete = useCallback( 120 + async (rkey: string) => { 121 + setDeletingRkey(rkey) 122 + try { 123 + await onDeleteWebhook(rkey) 124 + } catch (err) { 125 + alert(err instanceof Error ? err.message : 'Failed to delete webhook') 126 + } finally { 127 + setDeletingRkey(null) 128 + } 129 + }, 130 + [onDeleteWebhook], 131 + ) 132 + 119 133 // Keyboard navigation 120 134 useEffect(() => { 121 135 const handleKeyDown = (e: KeyboardEvent) => { ··· 147 161 148 162 window.addEventListener('keydown', handleKeyDown) 149 163 return () => window.removeEventListener('keydown', handleKeyDown) 150 - }, [webhooks, focusedWebhook, showCreateForm]) 164 + }, [webhooks, focusedWebhook, showCreateForm, handleDelete]) 151 165 152 166 // Scroll focused item into view 153 167 useEffect(() => { ··· 216 230 } 217 231 } 218 232 219 - const handleDelete = async (rkey: string) => { 220 - setDeletingRkey(rkey) 221 - try { 222 - await onDeleteWebhook(rkey) 223 - } catch (err) { 224 - alert(err instanceof Error ? err.message : 'Failed to delete webhook') 225 - } finally { 226 - setDeletingRkey(null) 227 - } 228 - } 229 - 230 233 const Kbd = ({ children }: { children: React.ReactNode }) => ( 231 234 <kbd className="px-2 py-1 bg-muted/50 rounded border border-border/50">{children}</kbd> 232 235 ) 233 236 234 237 return ( 238 + // biome-ignore lint/a11y/noStaticElementInteractions: keyboard navigation container, interaction handled via window keydown listener 239 + // biome-ignore lint/a11y/useKeyWithClickEvents: onClick only focuses container, keyboard nav handled via useEffect 235 240 <div 236 241 ref={containerRef} 237 242 className="h-full flex flex-col border border-border/30 bg-card/50 font-mono outline-none" ··· 308 313 <Input 309 314 id="wh-url" 310 315 value={url} 311 - onChange={(e) => setUrl(e.target.value)} 316 + onChange={(e: ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)} 312 317 placeholder="https://example.com/webhook" 313 318 required 314 319 className="h-8 text-sm font-mono" ··· 361 366 <input 362 367 id="wh-path" 363 368 value={scopePath} 364 - onChange={(e) => setScopePath(e.target.value)} 369 + onChange={(e: ChangeEvent<HTMLInputElement>) => setScopePath(e.target.value)} 365 370 placeholder="app.bsky.*" 366 371 className="flex-1 px-2.5 py-1.5 text-xs bg-transparent outline-none font-mono" 367 372 /> ··· 396 401 {otherMode === 'collection' && ( 397 402 <Input 398 403 value={otherCollection} 399 - onChange={(e) => setOtherCollection(e.target.value)} 404 + onChange={(e: ChangeEvent<HTMLInputElement>) => setOtherCollection(e.target.value)} 400 405 placeholder="app.bsky.feed.post" 401 406 className="h-8 text-xs font-mono" 402 407 /> ··· 405 410 <div className="flex gap-2"> 406 411 <Input 407 412 value={otherCollection} 408 - onChange={(e) => setOtherCollection(e.target.value)} 413 + onChange={(e: ChangeEvent<HTMLInputElement>) => setOtherCollection(e.target.value)} 409 414 placeholder="collection" 410 415 className="h-8 text-xs flex-1 font-mono" 411 416 /> 412 417 <Input 413 418 value={otherRkey} 414 - onChange={(e) => setOtherRkey(e.target.value)} 419 + onChange={(e: ChangeEvent<HTMLInputElement>) => setOtherRkey(e.target.value)} 415 420 placeholder="rkey" 416 421 className="h-8 text-xs flex-1 font-mono" 417 422 /> ··· 431 436 {/* Wildcard hint */} 432 437 {scopeAturi.includes('*') && ( 433 438 <p className="text-xs text-muted-foreground"> 434 - <code className="bg-muted/50 px-1.5 py-0.5 rounded-sm border border-border/20">*</code> matches any collection name at that level 439 + <code className="bg-muted/50 px-1.5 py-0.5 rounded-sm border border-border/20">*</code> matches any 440 + collection name at that level 435 441 </p> 436 442 )} 437 443 ··· 440 446 <div className="flex flex-col sm:flex-row sm:items-start gap-4 pt-1"> 441 447 {/* Backlinks */} 442 448 <div className="flex items-center gap-2"> 443 - <Checkbox id="wh-backlinks" checked={backlinks} onCheckedChange={(v) => setBacklinks(!!v)} /> 449 + <Checkbox 450 + id="wh-backlinks" 451 + checked={backlinks} 452 + onCheckedChange={(v: boolean | 'indeterminate') => setBacklinks(!!v)} 453 + /> 444 454 <Label htmlFor="wh-backlinks" className="cursor-pointer text-xs"> 445 455 Backlinks 446 456 </Label> ··· 457 467 ] as const 458 468 ).map(([name, val, set]) => ( 459 469 <div key={name} className="flex items-center gap-1.5"> 460 - <Checkbox id={`wh-event-${name}`} checked={val} onCheckedChange={(v) => set(!!v)} /> 470 + <Checkbox 471 + id={`wh-event-${name}`} 472 + checked={val} 473 + onCheckedChange={(v: boolean | 'indeterminate') => set(!!v)} 474 + /> 461 475 <Label htmlFor={`wh-event-${name}`} className="cursor-pointer text-xs capitalize"> 462 476 {name} 463 477 </Label> ··· 512 526 </p> 513 527 </div> 514 528 {!showCreateForm && ( 515 - <Button 516 - variant="outline" 517 - size="sm" 518 - className="text-xs" 519 - onClick={() => setShowCreateForm(true)} 520 - > 529 + <Button variant="outline" size="sm" className="text-xs" onClick={() => setShowCreateForm(true)}> 521 530 <Plus className="w-3 h-3 mr-1.5" /> 522 531 Create your first webhook 523 532 </Button> ··· 543 552 > 544 553 <div className="space-y-1.5 min-w-0 flex-1"> 545 554 <div className="flex items-center gap-2"> 546 - <div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${wh.enabled ? 'bg-green-500' : 'bg-muted-foreground/30'}`} /> 555 + <div 556 + className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${wh.enabled ? 'bg-green-500' : 'bg-muted-foreground/30'}`} 557 + /> 547 558 <p className="text-xs font-medium truncate">{wh.url}</p> 548 559 <a 549 560 href={wh.url} ··· 567 578 backlinks 568 579 </Badge> 569 580 )} 570 - {wh.events.length > 0 571 - ? wh.events.map((e) => ( 572 - <Badge key={e} variant="outline" className="text-[10px]"> 573 - {e} 574 - </Badge> 575 - )) 576 - : ( 577 - <Badge variant="outline" className="text-[10px]"> 578 - all events 581 + {wh.events.length > 0 ? ( 582 + wh.events.map((e) => ( 583 + <Badge key={e} variant="outline" className="text-[10px]"> 584 + {e} 579 585 </Badge> 580 - )} 586 + )) 587 + ) : ( 588 + <Badge variant="outline" className="text-[10px]"> 589 + all events 590 + </Badge> 591 + )} 581 592 </div> 582 593 </div> 583 594 <Button ··· 603 614 {/* Event Logs */} 604 615 <div className="p-4 border-t border-border/30 space-y-2"> 605 616 <div className="flex items-center justify-between mb-3"> 606 - <p className="text-xs uppercase tracking-wider text-muted-foreground"> 607 - Recent Deliveries 608 - </p> 617 + <p className="text-xs uppercase tracking-wider text-muted-foreground">Recent Deliveries</p> 609 618 <Button 610 619 variant="outline" 611 620 size="sm" ··· 634 643 className="flex items-center gap-3 p-2.5 border border-border/20 hover:bg-muted/10 transition-colors" 635 644 > 636 645 {/* Status indicator */} 637 - <div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${ 638 - log.status === 'ok' ? 'bg-green-500' : 'bg-red-500' 639 - }`} /> 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 + /> 640 651 641 652 {/* Event info */} 642 653 <div className="flex-1 min-w-0"> 643 654 <div className="flex items-center gap-2"> 644 - <Badge 645 - variant={log.status === 'ok' ? 'default' : 'destructive'} 646 - className="text-[10px]" 647 - > 655 + <Badge variant={log.status === 'ok' ? 'default' : 'destructive'} className="text-[10px]"> 648 656 {log.status === 'ok' ? '200' : 'ERR'} 649 657 </Badge> 650 658 <span className="text-xs font-medium capitalize">{log.eventKind}</span> ··· 653 661 <div className="flex items-center gap-2 mt-0.5"> 654 662 <span className="text-[10px] text-muted-foreground truncate max-w-[12rem]">{log.url}</span> 655 663 <span className="text-[10px] text-muted-foreground/50">•</span> 656 - <span className="text-[10px] text-muted-foreground whitespace-nowrap">{formatTimeAgo(log.deliveredAt)}</span> 664 + <span className="text-[10px] text-muted-foreground whitespace-nowrap"> 665 + {formatTimeAgo(log.deliveredAt)} 666 + </span> 657 667 </div> 658 668 </div> 659 669 </div>
+4 -2
apps/webhook-service/src/index.ts
··· 1 1 import { createLogger } from '@wispplace/observability' 2 2 import { config } from './config' 3 - import { closeDatabase, db, loadAllWebhooks } from './lib/db' 4 3 import { runStartupBackfill } from './lib/backfill' 4 + import { closeDatabase, db, loadAllWebhooks } from './lib/db' 5 5 import { getFirehoseHealth, initScopeDids, startFirehose, stopFirehose } from './lib/firehose' 6 6 import { closeRedisPublisher } from './lib/redis' 7 7 ··· 112 112 if (webhooks.length === 0) { 113 113 logger.info('[registry] No webhook records in DB') 114 114 } else { 115 - logger.info(`[registry] Tracking ${webhooks.length} webhook(s) across ${new Set(webhooks.map((w) => w.record.scope.aturi.replace(/^at:\/\//, '').split('/')[0])).size} DID(s)`) 115 + logger.info( 116 + `[registry] Tracking ${webhooks.length} webhook(s) across ${new Set(webhooks.map((w) => w.record.scope.aturi.replace(/^at:\/\//, '').split('/')[0])).size} DID(s)`, 117 + ) 116 118 for (const w of webhooks) { 117 119 logger.info( 118 120 `[registry] ${w.did}/${w.rkey}` +
+18 -5
apps/webhook-service/src/lib/firehose.ts
··· 7 7 findWebhooksForDid, 8 8 loadAllWebhooks, 9 9 upsertWebhookRecord, 10 - type WebhookEntry, 11 10 } from './db' 12 11 import { deliverWebhook } from './delivery' 13 12 import { JetstreamClient, type JetstreamEvent } from './jetstream' ··· 112 111 return combined 113 112 } 114 113 115 - async function deliver(did: string, collection: string, rkey: string, op: string, cid: string | undefined, record: unknown): Promise<void> { 114 + async function deliver( 115 + did: string, 116 + collection: string, 117 + rkey: string, 118 + op: string, 119 + cid: string | undefined, 120 + record: unknown, 121 + ): Promise<void> { 116 122 const candidates = await getWebhooksForEvent(did, record) 117 123 if (candidates.length === 0) return 118 124 ··· 151 157 } 152 158 invalidate(did) 153 159 invalidate('__backlinks__') 154 - loadAllWebhooks().then(initScopeDids).catch(() => {}) 160 + loadAllWebhooks() 161 + .then(initScopeDids) 162 + .catch(() => {}) 155 163 } 156 164 157 165 let directJetstream: JetstreamClient | null = null ··· 191 199 cursor, 192 200 onEvent: handleDirectEvent, 193 201 onError: (err) => logger.error('Direct Jetstream error', err), 194 - onConnect: () => { isConnected = true; logger.info('Direct Jetstream connected') }, 195 - onDisconnect: () => { isConnected = false }, 202 + onConnect: () => { 203 + isConnected = true 204 + logger.info('Direct Jetstream connected') 205 + }, 206 + onDisconnect: () => { 207 + isConnected = false 208 + }, 196 209 }) 197 210 directJetstream.start() 198 211 }
-1
apps/webhook-service/src/lib/jetstream.ts
··· 27 27 onConnect?: () => void 28 28 onDisconnect?: () => void 29 29 onError?: (err: Error) => void 30 - 31 30 } 32 31 33 32 export class JetstreamClient {
+2
bun.lock
··· 5 5 "": { 6 6 "name": "@wisp/monorepo", 7 7 "dependencies": { 8 + "@napi-rs/keyring": "^1.2.0", 8 9 "@tailwindcss/cli": "^4.1.17", 9 10 "bun-plugin-tailwind": "^0.1.2", 10 11 "node-html-parser": "^7.1.0", 11 12 "tailwindcss": "^4.1.17", 13 + "typescript": "^5.9.3", 12 14 }, 13 15 "devDependencies": { 14 16 "@biomejs/biome": "^2.4.6",
+7 -3
cli/commands/deploy.ts
··· 73 73 74 74 function toUnixPath(p: string): string { 75 75 // A backslash is a path separator on Windows 76 - if (process.platform === "win32") { 77 - return p.replace(/\\/g, "/") 76 + if (process.platform === 'win32') { 77 + return p.replace(/\\/g, '/') 78 78 } 79 79 // on Unix systems, you're typically allowed to have backslashes in file names 80 80 return p ··· 513 513 // Check individual file sizes 514 514 for (const file of files) { 515 515 if (file.size > MAX_FILE_SIZE) { 516 - console.log(pc.yellow(`\nWarning: ${file.relativePath} exceeds max size (${formatBytes(file.size)} > ${formatBytes(MAX_FILE_SIZE)})`)) 516 + console.log( 517 + pc.yellow( 518 + `\nWarning: ${file.relativePath} exceeds max size (${formatBytes(file.size)} > ${formatBytes(MAX_FILE_SIZE)})`, 519 + ), 520 + ) 517 521 console.log(pc.yellow('This file may not be cached by the hosting service.\n')) 518 522 } 519 523 }
+28 -31
cli/lib/auth.ts
··· 11 11 type NodeSavedStateStore, 12 12 requestLocalLock, 13 13 } from '@atproto/oauth-client-node' 14 + import { confirm, log } from '@clack/prompts' 14 15 import { serve as honoNodeServe } from '@hono/node-server' 16 + import { Entry as KeyringEntry } from '@napi-rs/keyring' 15 17 import { resolvePdsFromHandle } from '@wispplace/atproto-utils' 16 18 import { isBun } from '@wispplace/bun-firehose' 17 - import { Entry as KeyringEntry } from '@napi-rs/keyring' 18 - import { confirm, log } from '@clack/prompts' 19 19 import { Hono } from 'hono' 20 20 import open from 'open' 21 + import { WISP_OAUTH_SCOPE } from './wisp-service' 21 22 22 23 const KEYCHAIN_SERVICE = 'wispctl' 23 24 ··· 32 33 return false 33 34 } 34 35 } 35 - 36 - // All scopes requested upfront so the client_id is stable across commands 37 - const OAUTH_SCOPE = [ 38 - 'atproto', 39 - 'repo:place.wisp.fs', 40 - 'repo:place.wisp.subfs', 41 - 'repo:place.wisp.settings', 42 - 'blob:*/*', 43 - 'rpc:place.wisp.v2.site.getList?aud=*', 44 - 'rpc:place.wisp.v2.site.delete?aud=*', 45 - 'rpc:place.wisp.v2.domain.getList?aud=*', 46 - 'rpc:place.wisp.v2.domain.claim?aud=*', 47 - 'rpc:place.wisp.v2.domain.claimSubdomain?aud=*', 48 - 'rpc:place.wisp.v2.domain.getStatus?aud=*', 49 - 'rpc:place.wisp.v2.domain.addSite?aud=*', 50 - 'rpc:place.wisp.v2.domain.delete?aud=*', 51 - ].join(' ') 52 36 53 37 const DEFAULT_DB_PATH = join(homedir(), '.config', 'wispctl', 'state.sqlite') 54 38 ··· 92 76 const prefixStmt = db.query<{ value: string }, [string]>('SELECT value FROM kv WHERE key LIKE ?') 93 77 return { 94 78 get: (key) => getStmt.get(key) ?? undefined, 95 - set: (key, value, expiresAt) => { setStmt.run(key, value, expiresAt) }, 96 - del: (key) => { delStmt.run(key) }, 79 + set: (key, value, expiresAt) => { 80 + setStmt.run(key, value, expiresAt) 81 + }, 82 + del: (key) => { 83 + delStmt.run(key) 84 + }, 97 85 clear: () => db.run('DELETE FROM kv'), 98 86 valuesByPrefix: (prefix) => prefixStmt.all(`${prefix}%`).map((r) => r.value), 99 87 } ··· 108 96 const prefixStmt = db.prepare('SELECT value FROM kv WHERE key LIKE ?') 109 97 return { 110 98 get: (key) => getStmt.get(key) as KvRow | undefined, 111 - set: (key, value, expiresAt) => { setStmt.run(key, value, expiresAt) }, 112 - del: (key) => { delStmt.run(key) }, 99 + set: (key, value, expiresAt) => { 100 + setStmt.run(key, value, expiresAt) 101 + }, 102 + del: (key) => { 103 + delStmt.run(key) 104 + }, 113 105 clear: () => db.exec('DELETE FROM kv'), 114 106 valuesByPrefix: (prefix) => (prefixStmt.all(`${prefix}%`) as { value: string }[]).map((r) => r.value), 115 107 } ··· 163 155 } 164 156 }, 165 157 async del(sub) { 166 - try { new KeyringEntry(KEYCHAIN_SERVICE, sub).deletePassword() } catch {} 158 + try { 159 + new KeyringEntry(KEYCHAIN_SERVICE, sub).deletePassword() 160 + } catch {} 167 161 }, 168 162 } 169 163 } ··· 225 219 ): Promise<{ agent: Agent; did: string }> { 226 220 const kv = await openKv(options.dbPath || DEFAULT_DB_PATH) 227 221 228 - let useKeychain = probeKeychain() 222 + const useKeychain = probeKeychain() 229 223 if (!useKeychain) { 230 224 log.warn('System keychain is unavailable (no Secret Service daemon or equivalent).') 231 225 const fallback = await confirm({ 232 - message: 'Fall back to storing session tokens unencrypted in SQLite? (On headless systems, prefer --password instead.)', 226 + message: 227 + 'Fall back to storing session tokens unencrypted in SQLite? (On headless systems, prefer --password instead.)', 233 228 initialValue: false, 234 229 }) 235 230 if (!fallback) { ··· 240 235 const redirectUri = `http://${LOOPBACK_HOST}:${LOOPBACK_PORT}/oauth/callback` 241 236 const clientIdParams = new URLSearchParams() 242 237 clientIdParams.append('redirect_uri', redirectUri) 243 - clientIdParams.append('scope', OAUTH_SCOPE) 238 + clientIdParams.append('scope', WISP_OAUTH_SCOPE) 244 239 245 240 const client = new NodeOAuthClient({ 246 241 clientMetadata: { ··· 252 247 response_types: ['code'], 253 248 application_type: 'web', 254 249 token_endpoint_auth_method: 'none', 255 - scope: OAUTH_SCOPE, 250 + scope: WISP_OAUTH_SCOPE, 256 251 dpop_bound_access_tokens: false, 257 252 }, 258 253 stateStore: createStateStore(kv), ··· 393 388 } 394 389 }) 395 390 396 - const authUrl = await client.authorize(handle, { scope: OAUTH_SCOPE }) 391 + const authUrl = await client.authorize(handle, { scope: WISP_OAUTH_SCOPE }) 397 392 398 393 emitStatus(options, 'Opening browser for authentication...') 399 394 emitStatus(options, `If browser does not open, visit: ${authUrl}`) ··· 404 399 405 400 const tokenInfo = await session.getTokenInfo(false) 406 401 const grantedScopes = new Set((tokenInfo.scope || '').split(/\s+/).filter(Boolean)) 407 - const missingScopes = OAUTH_SCOPE.split(' ').filter((s) => !grantedScopes.has(decodeURIComponent(s))) 402 + const missingScopes = WISP_OAUTH_SCOPE.split(' ').filter((s) => !grantedScopes.has(decodeURIComponent(s))) 408 403 if (missingScopes.length > 0) { 409 404 emitWarning( 410 405 options, ··· 484 479 // Delete any keychain entries for DIDs we know about via dir mappings 485 480 const dids = kv.valuesByPrefix('dir:') 486 481 for (const did of dids) { 487 - try { new KeyringEntry(KEYCHAIN_SERVICE, did).deletePassword() } catch {} 482 + try { 483 + new KeyringEntry(KEYCHAIN_SERVICE, did).deletePassword() 484 + } catch {} 488 485 } 489 486 kv.clear() 490 487 console.log('Cleared all stored OAuth sessions')
+15 -2
cli/lib/wisp-service.ts
··· 1 1 export const DEFAULT_WISP_SERVICE_DID = 'did:web:wisp.place' 2 2 export const WISP_PROXY_SERVICE_ID = 'wisp_xrpc' 3 3 4 + export const WISP_OAUTH_BASE_SCOPES = [ 5 + 'atproto', 6 + 'repo:place.wisp.fs', 7 + 'repo:place.wisp.subfs', 8 + 'repo:place.wisp.settings', 9 + 'blob:*/*', 10 + ] as const 11 + 4 12 export const WISP_SERVICE_LXMS = [ 5 13 'place.wisp.v2.domain.addSite', 6 14 'place.wisp.v2.domain.claim', ··· 47 55 return `rpc:${lxm}?aud=${aud}` 48 56 } 49 57 50 - export function buildWispRpcScopes(aud: `did:${string}:${string}`): string[] { 51 - void aud 58 + export function buildWispRpcScopes(): string[] { 52 59 return WISP_SERVICE_LXMS.map((lxm) => buildRpcScope('*', lxm)) 53 60 } 61 + 62 + export function buildWispOAuthScopes(): string[] { 63 + return [...WISP_OAUTH_BASE_SCOPES, ...buildWispRpcScopes()] 64 + } 65 + 66 + export const WISP_OAUTH_SCOPE = buildWispOAuthScopes().join(' ')
+3 -1
package.json
··· 11 11 "cli" 12 12 ], 13 13 "dependencies": { 14 + "@napi-rs/keyring": "^1.2.0", 14 15 "@tailwindcss/cli": "^4.1.17", 15 16 "bun-plugin-tailwind": "^0.1.2", 16 17 "node-html-parser": "^7.1.0", 17 - "tailwindcss": "^4.1.17" 18 + "tailwindcss": "^4.1.17", 19 + "typescript": "^5.9.3" 18 20 }, 19 21 "scripts": { 20 22 "test": "bun test",
+1 -1
packages/@wispplace/tiered-storage/example.ts
··· 184 184 if (aboutPage) { 185 185 console.log(` Source: ${aboutPage.source} tier`) 186 186 console.log(` Access count: ${aboutPage.metadata.accessCount}`) 187 - console.log(` Preview: ${aboutPage.data.toString().slice(0, 100)}...`) 187 + console.log(` Preview: ${(aboutPage.data as Uint8Array).toString().slice(0, 100)}...`) 188 188 } 189 189 190 190 // Invalidate entire site
+4 -5
packages/@wispplace/tiered-storage/serve-example.ts
··· 33 33 }), 34 34 cold: new S3StorageTier({ 35 35 bucket: S3_BUCKET, 36 - metadataBucket: S3_METADATA_BUCKET, 37 36 region: S3_REGION, 38 37 endpoint: S3_ENDPOINT, 39 38 forcePathStyle: S3_FORCE_PATH_STYLE, ··· 388 387 389 388 try { 390 389 // Clear hot tier (memory) 391 - if (storage.config.tiers.hot) { 392 - await storage.config.tiers.hot.clear() 390 + if ((storage as any).config.tiers.hot) { 391 + await (storage as any).config.tiers.hot.clear() 393 392 console.log('✓ Hot tier (memory) cleared') 394 393 } 395 394 396 395 // Clear warm tier (disk) 397 - if (storage.config.tiers.warm) { 398 - await storage.config.tiers.warm.clear() 396 + if ((storage as any).config.tiers.warm) { 397 + await (storage as any).config.tiers.warm.clear() 399 398 console.log('✓ Warm tier (disk) cleared') 400 399 } 401 400
+1 -1
packages/@wispplace/tiered-storage/src/tiers/DiskStorageTier.ts
··· 175 175 size: fileStats.size, 176 176 createdAt: new Date(metadata.createdAt), 177 177 lastAccessed: new Date(metadata.lastAccessed), 178 - ...(metadata.ttl && { ttl: new Date(metadata.ttl) }), 178 + ...(metadata.ttl && { ttl: new Date(metadata.ttl) }), 179 179 }) 180 180 181 181 this.currentSize += fileStats.size
+2 -2
packages/@wispplace/tiered-storage/test/DiskStorageTier.gc.test.ts
··· 1 - import { rm } from 'node:fs/promises' 2 1 import { afterAll, beforeEach, describe, expect, test } from 'bun:test' 2 + import { rm } from 'node:fs/promises' 3 3 import { DiskStorageTier } from '../src/tiers/DiskStorageTier.js' 4 4 import type { StorageMetadata } from '../src/types/index.js' 5 5 ··· 253 253 // (stale timestamp in metadataIndex). With the fix, 'b' is evicted instead. 254 254 await tier.set('c', data, makeMetadata('c', data.byteLength)) 255 255 256 - expect(await tier.exists('a')).toBe(true) // freshly touched — must survive 256 + expect(await tier.exists('a')).toBe(true) // freshly touched — must survive 257 257 expect(await tier.exists('b')).toBe(false) // true LRU victim 258 258 expect(await tier.exists('c')).toBe(true) 259 259 })
+1 -1
packages/@wispplace/tiered-storage/test/DiskStorageTier.test.ts
··· 1 + import { afterAll, beforeEach, describe, expect, test } from 'bun:test' 1 2 import { existsSync } from 'node:fs' 2 3 import { readdir, rm } from 'node:fs/promises' 3 4 import { join } from 'node:path' 4 - import { afterAll, beforeEach, describe, expect, test } from 'bun:test' 5 5 import { DiskStorageTier } from '../src/tiers/DiskStorageTier.js' 6 6 7 7 const testDir = './test-disk-cache'
+1 -1
packages/@wispplace/tiered-storage/test/S3StorageTier.test.ts
··· 1 + import { describe, expect, test } from 'bun:test' 1 2 import { Readable } from 'node:stream' 2 3 import { gzipSync } from 'node:zlib' 3 - import { describe, expect, test } from 'bun:test' 4 4 import { S3StorageTier } from '../src/tiers/S3StorageTier.js' 5 5 6 6 describe('S3StorageTier metadata fallback', () => {
+1 -1
packages/@wispplace/tiered-storage/test/TieredStorage.test.ts
··· 1 - import { rm } from 'node:fs/promises' 2 1 import { afterAll, beforeEach, describe, expect, test } from 'bun:test' 2 + import { rm } from 'node:fs/promises' 3 3 import { TieredStorage } from '../src/TieredStorage.js' 4 4 import { DiskStorageTier } from '../src/tiers/DiskStorageTier.js' 5 5 import { MemoryStorageTier } from '../src/tiers/MemoryStorageTier.js'
+1 -1
packages/@wispplace/tiered-storage/test/streaming.test.ts
··· 1 + import { afterAll, beforeEach, describe, expect, test } from 'bun:test' 1 2 import { createHash } from 'node:crypto' 2 3 import { rm } from 'node:fs/promises' 3 4 import { Readable } from 'node:stream' 4 - import { afterAll, beforeEach, describe, expect, test } from 'bun:test' 5 5 import { TieredStorage } from '../src/TieredStorage.js' 6 6 import { DiskStorageTier } from '../src/tiers/DiskStorageTier.js' 7 7 import { MemoryStorageTier } from '../src/tiers/MemoryStorageTier.js'
+8 -4
tsconfig.json
··· 101 101 /* Completeness */ 102 102 // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 103 103 "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 104 + "allowImportingTsExtensions": true, 105 + "noEmit": true, 104 106 "baseUrl": ".", 105 107 "paths": { 106 - "@server": ["./src/index.ts"], 107 - "@server/*": ["./src/*"], 108 - "@public/*": ["./public/*"], 108 + "@server": ["./apps/main-app/src/index.ts"], 109 + "@server/*": ["./apps/main-app/src/*"], 110 + "@public/*": ["./apps/main-app/public/*"], 111 + "@wispplace/*": ["./packages/@wispplace/*/src"] 109 112 } 110 - } 113 + }, 114 + "exclude": ["docs", "node_modules"] 111 115 }