feat: relay liveness tracking

This commit is contained in:
Alejandro Gómez
2025-12-17 10:26:59 +01:00
parent 8fe0ffd5c3
commit c9a6df928e
9 changed files with 445 additions and 82 deletions

View File

@@ -23,7 +23,13 @@ Grimoire is a Nostr protocol explorer and developer tool. It's a tiling window m
- Reactive: components subscribe via hooks, auto-update on new events
- Handles replaceable events automatically (profiles, contact lists, etc.)
**Critical**: Don't create new EventStore or RelayPool instances - use the singletons in `src/services/`
**Relay State** (`src/services/relay-liveness.ts`):
- Singleton `RelayLiveness` tracks relay health across sessions
- Persisted to Dexie `relayLiveness` table
- Maintains failure counts, backoff states, last success/failure times
- Prevents repeated connection attempts to dead relays
**Critical**: Don't create new EventStore, RelayPool, or RelayLiveness instances - use the singletons in `src/services/`
### Window System
@@ -82,11 +88,23 @@ Use hooks like `useProfile()`, `useNostrEvent()`, `useTimeline()` - they handle
- Active account stored in Jotai state, synced via `useAccountSync` hook
- Use inbox/outbox relay pattern for user relay lists
**Event Rendering**:
- Feed renderers: `KindRenderer` component with `renderers` registry in `src/components/nostr/kinds/index.tsx`
- Detail renderers: `DetailKindRenderer` component with `detailRenderers` registry
- Registry pattern allows adding new kind renderers without modifying parent components
- Falls back to `DefaultKindRenderer` or feed renderer for unregistered kinds
**Mosaic Layout**:
- Layout mutations via `updateLayout()` callback only
- Don't traverse or modify layout tree manually
- Adding/removing windows handled by `logic.ts` functions
**Error Boundaries**:
- All event renderers wrapped in `EventErrorBoundary` component
- Prevents one broken event from crashing entire feed or detail view
- Provides diagnostic UI with retry capability and error details
- Error boundaries auto-reset when event changes
## Testing
**Test Framework**: Vitest with node environment

72
TODO.md
View File

@@ -24,24 +24,11 @@ Current RTL implementation is partial and has limitations:
**Test case**: Arabic text with hashtags on same line should display properly with right-alignment.
### Live Mode Reliability
**Priority**: High
**File**: `src/components/ReqViewer.tsx`
**Issues**:
- Live mode sometimes stops updating (gets stuck)
- May be related to reconnection on errors
- Compact live indicator needed for better UX
**Investigation needed**: Check relay reconnection logic and subscription lifecycle.
### Rendering Issues
**Priority**: Medium
- **Window crashes on unsupported kind event** - Need graceful error handling for unknown kinds
- **Nested lists in Markdown should be padded** - Markdown renderer spacing issue
- **Text rendering**: Avoid inserting `<br>` tags, investigate noStrudel's EOL metadata approach
- **JSON viewer scrolling**: Expandable JSON in event details cannot be scrolled when content exceeds available height - needs overflow handling
## Command Palette / UX Improvements
@@ -88,9 +75,41 @@ When an action is entered, show the list of available options below and provide
**Priority**: Low
**Description**: Allow setting background color or theme for individual columns, helping visually organize workspace.
## Recent Improvements ✅
### Relay Liveness Persistence
**Completed**: 2024-12-17
**Files**: `src/services/db.ts`, `src/services/relay-liveness.ts`
Relay health tracking now persists across sessions:
- Added Dexie v8 migration with `relayLiveness` table
- Created storage adapter implementing `LivenessStorage` interface
- Relay failure counts, backoff states persist across app restarts
- Prevents repeated connection attempts to dead relays
### Detail Renderer Registry
**Completed**: 2024-12-17
**Files**: `src/components/nostr/kinds/index.tsx`, `src/components/EventDetailViewer.tsx`
Refactored detail view rendering to use registry pattern:
- Removed 25-line hardcoded if-else chain from EventDetailViewer
- Created `detailRenderers` map with 11 specialized detail renderers
- New detail renderers can be added without modifying EventDetailViewer
- Falls back to feed renderer for kinds without custom detail views
### Event Error Boundaries
**Completed**: 2024-12-17
**Files**: `src/components/EventErrorBoundary.tsx`, `src/components/nostr/Feed.tsx`, `src/components/EventDetailViewer.tsx`
All event renderers now protected with error boundaries:
- One broken event no longer crashes entire feed
- Diagnostic UI shows kind, ID, error message, component stack
- Retry button and collapsible details for debugging
- Auto-resets when event changes
## Planned Improvements
- **App-wide error boundary** - Splash crash screen for unhandled errors
- **App-wide error boundary** - Splash crash screen for unhandled errors (separate from event-level boundaries)
- **Collapsible relay list** - Show user relay links without inbox/outbox icons initially
- **NIP badges everywhere** - Use consistent NIP badge components for linking to NIP documentation
- **External spec event kind support** - Add references and documentation links for commented-out event kinds from external specs (Blossom, Marmot Protocol, NKBIP, nostrocket, Corny Chat, NUD, etc.) in `src/constants/kinds.ts`. Consider adding a separate registry or documentation for non-official-NIP event kinds.
@@ -258,20 +277,7 @@ When an action is entered, show the list of available options below and provide
### Phase 1: Foundation Fixes (1-2 weeks)
**Goal:** Fix critical architectural issues and quick wins
#### 1.1 Unified Detail Renderer Registry
**Priority**: High | **Effort**: Low
**Files**: `src/components/nostr/kinds/index.tsx`, `src/components/EventDetailViewer.tsx`
**Problem**: EventDetailViewer uses hardcoded switch statement instead of registry pattern
```tsx
// Current anti-pattern:
event.kind === kinds.Metadata ? <Kind0DetailRenderer />
: event.kind === kinds.Contacts ? <Kind3DetailView />
```
**Solution**: Create `detailRenderers` map parallel to `kindRenderers` with fallback logic
#### 1.2 Systematic Depth Tracking
#### 1.1 Systematic Depth Tracking
**Priority**: High | **Effort**: Medium
**Files**: All `*Renderer.tsx` files
@@ -283,15 +289,7 @@ event.kind === kinds.Metadata ? <Kind0DetailRenderer />
- Audit all renderers using `EmbeddedEvent`
- Implement `CollapsedPreview` component for max depth exceeded
#### 1.3 Error Boundaries
**Priority**: High | **Effort**: Low
**File**: `src/components/EventErrorBoundary.tsx`
**Problem**: One broken event crashes entire feed
**Solution**: Create `EventErrorBoundary` component wrapping all events with diagnostic error cards
#### 1.4 Renderer Memoization
#### 1.2 Renderer Memoization
**Priority**: Medium | **Effort**: Low
**Files**: All `*Renderer.tsx` files

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import {
Wifi,
@@ -11,6 +11,10 @@ import {
Shield,
XCircle,
Settings,
Activity,
Clock,
AlertCircle,
Skull,
} from "lucide-react";
import { useRelayState } from "@/hooks/useRelayState";
import type { RelayState } from "@/types/relay-state";
@@ -26,6 +30,7 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { isAuthPreference } from "@/lib/type-guards";
import liveness from "@/services/relay-liveness";
/**
* CONN viewer - displays connection and auth status for all relays in the pool
@@ -44,6 +49,9 @@ function ConnViewer() {
.filter((r) => r.connectionState !== "connected")
.sort((a, b) => a.url.localeCompare(b.url));
// Get all seen relays for liveness section
const seenRelays = liveness.getSeenRelays().sort();
return (
<div className="h-full w-full flex flex-col bg-background text-foreground">
{/* Relay List */}
@@ -77,6 +85,18 @@ function ConnViewer() {
))}
</>
)}
{/* Relay Liveness Stats */}
{seenRelays.length > 0 && (
<>
<div className="px-4 py-2 bg-muted/30 text-xs font-semibold text-muted-foreground">
Relay Liveness
</div>
{seenRelays.map((url) => (
<LivenessStatsRow key={url} url={url} />
))}
</>
)}
</div>
</div>
);
@@ -253,4 +273,92 @@ function RelayCard({ relay }: RelayCardProps) {
);
}
interface LivenessStatsRowProps {
url: string;
}
function LivenessStatsRow({ url }: LivenessStatsRowProps) {
const [livenessState, setLivenessState] = useState(liveness.getState(url));
// Subscribe to liveness state updates
useEffect(() => {
const subscription = liveness.state(url).subscribe((state) => {
setLivenessState(state);
});
return () => subscription.unsubscribe();
}, [url]);
// Format liveness state icon and label
const livenessIcon = () => {
if (!livenessState) {
return { icon: <Activity className="size-4 text-muted-foreground" />, label: "Unknown" };
}
const iconMap = {
online: { icon: <Activity className="size-4 text-green-500" />, label: "Online" },
offline: { icon: <WifiOff className="size-4 text-yellow-500" />, label: "Offline" },
dead: { icon: <Skull className="size-4 text-red-500" />, label: "Dead" },
};
return iconMap[livenessState.state];
};
// Format backoff remaining time
const formatBackoffTime = (ms: number) => {
if (ms <= 0) return null;
const seconds = Math.ceil(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.ceil(seconds / 60);
return `${minutes}m`;
};
const backoffRemaining = livenessState ? liveness.getBackoffRemaining(url) : 0;
const isInBackoff = backoffRemaining > 0;
if (!livenessState) {
return null;
}
return (
<div className="border-b border-border px-4 py-2">
<div className="flex items-center justify-between gap-3">
<RelayLink
url={url}
showInboxOutbox={false}
className="line-clamp-1 hover:bg-transparent hover:underline hover:decoration-dotted"
iconClassname="size-4"
urlClassname="text-sm"
/>
<div className="flex items-center gap-4 text-xs text-muted-foreground font-mono flex-shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{livenessIcon().icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{livenessIcon().label}</p>
</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1.5">
<AlertCircle className="size-4" />
<span>{livenessState.failureCount}</span>
</div>
{isInBackoff && (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help flex items-center gap-1.5 text-yellow-500">
<Clock className="size-4" />
<span>{formatBackoffTime(backoffRemaining)}</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Backoff</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
</div>
);
}
export default ConnViewer;

View File

@@ -1,18 +1,8 @@
import { useState } from "react";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { KindRenderer } from "./nostr/kinds";
import { Kind0DetailRenderer } from "./nostr/kinds/ProfileDetailRenderer";
import { Kind3DetailView } from "./nostr/kinds/ContactListRenderer";
import { IssueDetailRenderer } from "./nostr/kinds/IssueDetailRenderer";
import { PatchDetailRenderer } from "./nostr/kinds/PatchDetailRenderer";
import { PullRequestDetailRenderer } from "./nostr/kinds/PullRequestDetailRenderer";
import { Kind1337DetailRenderer } from "./nostr/kinds/CodeSnippetDetailRenderer";
import { Kind9802DetailRenderer } from "./nostr/kinds/HighlightDetailRenderer";
import { Kind10002DetailRenderer } from "./nostr/kinds/RelayListDetailRenderer";
import { Kind30023DetailRenderer } from "./nostr/kinds/ArticleDetailRenderer";
import { CommunityNIPDetailRenderer } from "./nostr/kinds/CommunityNIPDetailRenderer";
import { RepositoryDetailRenderer } from "./nostr/kinds/RepositoryDetailRenderer";
import { DetailKindRenderer } from "./nostr/kinds";
import { EventErrorBoundary } from "./EventErrorBoundary";
import { JsonViewer } from "./JsonViewer";
import { RelayLink } from "./nostr/RelayLink";
import { EventDetailSkeleton } from "@/components/ui/skeleton";
@@ -24,7 +14,7 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { nip19, kinds } from "nostr-tools";
import { nip19 } from "nostr-tools";
import { useCopy } from "../hooks/useCopy";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import { getTagValue } from "applesauce-core/helpers";
@@ -182,31 +172,9 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
{/* Rendered Content - Focus Here */}
<div className="flex-1 overflow-y-auto">
{event.kind === kinds.Metadata ? (
<Kind0DetailRenderer event={event} />
) : event.kind === kinds.Contacts ? (
<Kind3DetailView event={event} />
) : event.kind === 1337 ? (
<Kind1337DetailRenderer event={event} />
) : event.kind === 1617 ? (
<PatchDetailRenderer event={event} />
) : event.kind === 1618 ? (
<PullRequestDetailRenderer event={event} />
) : event.kind === 1621 ? (
<IssueDetailRenderer event={event} />
) : event.kind === kinds.Highlights ? (
<Kind9802DetailRenderer event={event} />
) : event.kind === kinds.RelayList ? (
<Kind10002DetailRenderer event={event} />
) : event.kind === kinds.LongFormArticle ? (
<Kind30023DetailRenderer event={event} />
) : event.kind === 30817 ? (
<CommunityNIPDetailRenderer event={event} />
) : event.kind === 30617 ? (
<RepositoryDetailRenderer event={event} />
) : (
<KindRenderer event={event} />
)}
<EventErrorBoundary event={event}>
<DetailKindRenderer event={event} />
</EventErrorBoundary>
</div>
{/* JSON Viewer Dialog */}

View File

@@ -0,0 +1,169 @@
import React, { Component, ReactNode } from "react";
import { AlertTriangle, Bug, FileJson, RefreshCw } from "lucide-react";
import type { NostrEvent } from "@/types/nostr";
import { nip19 } from "nostr-tools";
import { Button } from "./ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
interface EventErrorBoundaryProps {
children: ReactNode;
event: NostrEvent;
}
interface EventErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
showDetails: boolean;
}
/**
* Error boundary for event renderers
* Catches rendering errors and displays diagnostic information
* Prevents one broken event from crashing the entire feed
*/
export class EventErrorBoundary extends Component<
EventErrorBoundaryProps,
EventErrorBoundaryState
> {
constructor(props: EventErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
showDetails: false,
};
}
static getDerivedStateFromError(_error: Error): Partial<EventErrorBoundaryState> {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log error to console for debugging
console.error("[EventErrorBoundary] Caught rendering error:", error, errorInfo);
this.setState({
error,
errorInfo,
});
}
componentDidUpdate(prevProps: EventErrorBoundaryProps) {
// Reset error boundary if event changes
if (prevProps.event.id !== this.props.event.id) {
this.setState({
hasError: false,
error: null,
errorInfo: null,
showDetails: false,
});
}
}
handleRetry = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
showDetails: false,
});
};
render() {
if (this.state.hasError) {
const { event } = this.props;
const { error, errorInfo, showDetails } = this.state;
// Generate event ID for debugging
const eventId = nip19.noteEncode(event.id);
return (
<div className="border border-destructive bg-destructive/10 p-4 my-2">
<div className="flex items-start gap-3">
<AlertTriangle className="size-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-destructive mb-1">
Rendering Error
</h3>
<p className="text-xs text-muted-foreground mb-3">
This event failed to render. The error has been logged to the console.
</p>
<div className="flex flex-wrap items-center gap-2 mb-3 text-xs">
<div className="flex items-center gap-1.5 bg-background/50 px-2 py-1 border border-border">
<Bug className="size-3" />
<span className="font-mono">Kind {event.kind}</span>
</div>
<div className="flex items-center gap-1.5 bg-background/50 px-2 py-1 border border-border">
<FileJson className="size-3" />
<span className="font-mono truncate max-w-[200px]" title={eventId}>
{eventId.slice(0, 16)}...
</span>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={this.handleRetry}
className="h-7 text-xs"
>
<RefreshCw className="size-3 mr-1" />
Retry
</Button>
<Collapsible
open={showDetails}
onOpenChange={(open) => this.setState({ showDetails: open })}
>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 text-xs">
{showDetails ? "Hide" : "Show"} Details
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="bg-background border border-border p-3 text-xs space-y-2">
{error && (
<div>
<div className="font-semibold text-destructive mb-1">
Error:
</div>
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words">
{error.toString()}
</pre>
</div>
)}
{errorInfo && errorInfo.componentStack && (
<div>
<div className="font-semibold text-destructive mb-1">
Component Stack:
</div>
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto">
{errorInfo.componentStack}
</pre>
</div>
)}
<div>
<div className="font-semibold text-destructive mb-1">
Event JSON:
</div>
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto">
{JSON.stringify(event, null, 2)}
</pre>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -2,6 +2,7 @@ import { useTimeline } from "@/hooks/useTimeline";
import { kinds } from "nostr-tools";
import { NostrEvent } from "@/types/nostr";
import { KindRenderer } from "./kinds";
import { EventErrorBoundary } from "../EventErrorBoundary";
interface FeedEventProps {
event: NostrEvent;
@@ -9,9 +10,14 @@ interface FeedEventProps {
/**
* FeedEvent - Renders a single event using the appropriate kind renderer
* Wrapped in error boundary to prevent one broken event from crashing the feed
*/
export function FeedEvent({ event }: FeedEventProps) {
return <KindRenderer event={event} />;
return (
<EventErrorBoundary event={event}>
<KindRenderer event={event} />
</EventErrorBoundary>
);
}
/**

View File

@@ -1,7 +1,9 @@
import { Kind0Renderer } from "./ProfileRenderer";
import { Kind0DetailRenderer } from "./ProfileDetailRenderer";
import { Kind1Renderer } from "./NoteRenderer";
import { Kind1111Renderer } from "./Kind1111Renderer";
import { Kind3Renderer } from "./ContactListRenderer";
import { Kind3DetailView } from "./ContactListRenderer";
import { RepostRenderer } from "./RepostRenderer";
import { Kind7Renderer } from "./ReactionRenderer";
import { Kind9Renderer } from "./ChatMessageRenderer";
@@ -10,15 +12,24 @@ import { Kind21Renderer } from "./VideoRenderer";
import { Kind22Renderer } from "./ShortVideoRenderer";
import { Kind1063Renderer } from "./FileMetadataRenderer";
import { Kind1337Renderer } from "./CodeSnippetRenderer";
import { Kind1337DetailRenderer } from "./CodeSnippetDetailRenderer";
import { IssueRenderer } from "./IssueRenderer";
import { IssueDetailRenderer } from "./IssueDetailRenderer";
import { PatchRenderer } from "./PatchRenderer";
import { PatchDetailRenderer } from "./PatchDetailRenderer";
import { PullRequestRenderer } from "./PullRequestRenderer";
import { PullRequestDetailRenderer } from "./PullRequestDetailRenderer";
import { Kind9735Renderer } from "./ZapReceiptRenderer";
import { Kind9802Renderer } from "./HighlightRenderer";
import { Kind9802DetailRenderer } from "./HighlightDetailRenderer";
import { Kind10002Renderer } from "./RelayListRenderer";
import { Kind10002DetailRenderer } from "./RelayListDetailRenderer";
import { Kind30023Renderer } from "./ArticleRenderer";
import { Kind30023DetailRenderer } from "./ArticleDetailRenderer";
import { CommunityNIPRenderer } from "./CommunityNIPRenderer";
import { CommunityNIPDetailRenderer } from "./CommunityNIPDetailRenderer";
import { RepositoryRenderer } from "./RepositoryRenderer";
import { RepositoryDetailRenderer } from "./RepositoryDetailRenderer";
import { Kind39701Renderer } from "./BookmarkRenderer";
import { GenericRelayListRenderer } from "./GenericRelayListRenderer";
import { NostrEvent } from "@/types/nostr";
@@ -92,10 +103,46 @@ export function KindRenderer({
return <Renderer event={event} depth={depth} />;
}
/**
* Registry of kind-specific detail renderers (for detail views)
* Maps event kinds to their detailed renderer components
*/
const detailRenderers: Record<number, React.ComponentType<{ event: NostrEvent }>> = {
0: Kind0DetailRenderer, // Profile Metadata Detail
3: Kind3DetailView, // Contact List Detail
1337: Kind1337DetailRenderer, // Code Snippet Detail (NIP-C0)
1617: PatchDetailRenderer, // Patch Detail (NIP-34)
1618: PullRequestDetailRenderer, // Pull Request Detail (NIP-34)
1621: IssueDetailRenderer, // Issue Detail (NIP-34)
9802: Kind9802DetailRenderer, // Highlight Detail
10002: Kind10002DetailRenderer, // Relay List Detail (NIP-65)
30023: Kind30023DetailRenderer, // Long-form Article Detail
30617: RepositoryDetailRenderer, // Repository Detail (NIP-34)
30817: CommunityNIPDetailRenderer, // Community NIP Detail
};
/**
* Default detail renderer for kinds without custom detail implementations
* Falls back to the feed renderer
*/
function DefaultDetailRenderer({ event }: { event: NostrEvent }) {
return <KindRenderer event={event} depth={0} />;
}
/**
* Main DetailKindRenderer component
* Automatically selects the appropriate detail renderer based on event kind
* Falls back to feed renderer if no detail renderer exists
*/
export function DetailKindRenderer({ event }: { event: NostrEvent }) {
const Renderer = detailRenderers[event.kind] || DefaultDetailRenderer;
return <Renderer event={event} />;
}
/**
* Export kind renderers registry for dynamic kind detection
*/
export { kindRenderers };
export { kindRenderers, detailRenderers };
/**
* Export individual renderers and base components for reuse

View File

@@ -40,6 +40,15 @@ export interface CachedRelayList {
updatedAt: number;
}
export interface RelayLivenessEntry {
url: string;
state: "online" | "offline" | "dead";
failureCount: number;
lastFailureTime: number;
lastSuccessTime: number;
backoffUntil?: number;
}
class GrimoireDb extends Dexie {
profiles!: Table<Profile>;
nip05!: Table<Nip05>;
@@ -47,6 +56,7 @@ class GrimoireDb extends Dexie {
relayInfo!: Table<RelayInfo>;
relayAuthPreferences!: Table<RelayAuthPreference>;
relayLists!: Table<CachedRelayList>;
relayLiveness!: Table<RelayLivenessEntry>;
constructor(name: string) {
super(name);
@@ -159,9 +169,42 @@ class GrimoireDb extends Dexie {
relayAuthPreferences: "&url",
relayLists: "&pubkey, updatedAt",
});
// Version 8: Add relay liveness tracking
this.version(8).stores({
profiles: "&pubkey",
nip05: "&nip05",
nips: "&id",
relayInfo: "&url",
relayAuthPreferences: "&url",
relayLists: "&pubkey, updatedAt",
relayLiveness: "&url",
});
}
}
const db = new GrimoireDb("grimoire-dev");
/**
* Dexie storage adapter for RelayLiveness persistence
* Implements the LivenessStorage interface expected by applesauce-relay
*/
export const relayLivenessStorage = {
async getItem(key: string): Promise<any> {
const entry = await db.relayLiveness.get(key);
if (!entry) return null;
// Return RelayState object without the url field
const { url, ...state } = entry;
return state;
},
async setItem(key: string, value: any): Promise<void> {
await db.relayLiveness.put({
url: key,
...value,
});
},
};
export default db;

View File

@@ -7,8 +7,9 @@
import { RelayLiveness } from "applesauce-relay";
import pool from "./relay-pool";
import { relayLivenessStorage } from "./db";
// Create singleton liveness tracker
// Create singleton liveness tracker with persistent storage
const liveness = new RelayLiveness({
// Maximum failures before marking relay as dead
maxFailuresBeforeDead: 5,
@@ -16,8 +17,13 @@ const liveness = new RelayLiveness({
backoffBaseDelay: 30 * 1000,
// Maximum backoff delay (5 minutes)
backoffMaxDelay: 5 * 60 * 1000,
// TODO: Add persistent storage using Dexie
// storage: undefined,
// Persistent storage using Dexie
storage: relayLivenessStorage,
});
// Load persisted relay states on initialization
liveness.load().catch((error) => {
console.warn("[RelayLiveness] Failed to load persisted state:", error);
});
// Connect to relay pool to automatically track relay health