Merge pull request #20 from purrgrammer/claude/improve-reqviewer-state-machine-cBkEO

docs: add comprehensive ReqViewer state machine analysis and improvement plan
This commit is contained in:
Alejandro
2025-12-22 19:51:21 +01:00
committed by GitHub
10 changed files with 3532 additions and 178 deletions

View File

@@ -29,6 +29,12 @@ Grimoire is a Nostr protocol explorer and developer tool. It's a tiling window m
- Maintains failure counts, backoff states, last success/failure times
- Prevents repeated connection attempts to dead relays
**Nostr Query State Machine** (`src/lib/req-state-machine.ts` + `src/hooks/useReqTimelineEnhanced.ts`):
- Accurate tracking of REQ subscriptions across multiple relays
- Distinguishes between `LIVE`, `LOADING`, `PARTIAL`, `OFFLINE`, `CLOSED`, and `FAILED` states
- Solves "LIVE with 0 relays" bug by tracking per-relay connection state and event counts
- Pattern: Subscribe to relays individually to detect per-relay EOSE and errors
**Critical**: Don't create new EventStore, RelayPool, or RelayLiveness instances - use the singletons in `src/services/`
### Window System

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,787 @@
# ReqViewer State Machine Analysis
**Date**: 2025-12-22
**Issue**: Disconnected relays are incorrectly shown as "LIVE" and counted as having sent EOSE
## Executive Summary
The ReqViewer state machine has a critical bug where relay disconnections are indistinguishable from EOSE messages, leading to incorrect status indicators. A query using 30 relays where all disconnect will show "LIVE" status with 0/30 relays connected.
## Architecture Overview
### Current Flow
```
User Query → useReqTimeline → pool.subscription → RelayGroup → Individual Relays
↓ ↓
setEoseReceived(true) ←── "EOSE" string ←── catchError → DISCONNECTION
Shows "LIVE" indicator
```
### Key Components
1. **ReqViewer** (`src/components/ReqViewer.tsx`):
- UI component that displays query results and status
- Lines 918-957: Status indicator logic based on `loading`, `eoseReceived`, `stream`
- Lines 735-737: Connected relay count based on `connectionState === "connected"`
2. **useReqTimeline** (`src/hooks/useReqTimeline.ts`):
- Hook that manages REQ subscription
- Line 88: Sets `eoseReceived = true` when response is string "EOSE"
- No awareness of relay disconnection state
3. **RelayPool** (applesauce-relay):
- `pool.subscription()` delegates to RelayGroup
- Uses retry/reconnect logic but doesn't expose per-relay EOSE state
4. **RelayGroup** (applesauce-relay/dist/group.js):
- **CRITICAL BUG HERE**: Line with `catchError(() => of("EOSE"))`
- Treats ANY error (including disconnection) as EOSE
- Aggregates EOSE from all relays before emitting overall EOSE
5. **Relay** (applesauce-relay/dist/relay.js):
- Individual relay connection
- Has 10-second EOSE timeout that emits fake EOSE if none received
- Emits observables: `connected$`, `challenge$`, `authenticated$`, `notice$`
## Critical Bug: Error Handling in RelayGroup
### The Problem
In `node_modules/applesauce-relay/dist/group.js`:
```javascript
const observable = project(relay).pipe(
// Catch connection errors and return EOSE
catchError(() => of("EOSE")), // ← BUG: Disconnections become EOSE!
map((value) => [relay, value])
);
```
**Why this is problematic**:
- A relay that never connected emits "EOSE"
- A relay that disconnects mid-query emits "EOSE"
- A relay with a WebSocket error emits "EOSE"
- These fake EOSE messages are indistinguishable from real ones
### EOSE Aggregation Logic
```javascript
const eose = this.relays$.pipe(
switchMap((relays) =>
main.pipe(
filter(([_, value]) => value === "EOSE"),
scan((received, [relay]) => [...received, relay], []),
// Wait until ALL relays have "sent" EOSE
takeWhile((received) => relays.some((r) => !received.includes(r))),
ignoreElements(),
endWith("EOSE") // ← Emits when all relays done (or errored)
)
)
);
```
**Result**: The overall EOSE is emitted when:
- ✅ All relays sent real EOSE and are streaming
- ✅ All relays sent real EOSE and closed connection
- ❌ All relays disconnected (caught and turned into fake EOSE)
- ❌ Mix of real EOSE and disconnections (can't tell the difference)
## Edge Cases & Failure Scenarios
### Scenario 1: All Relays Disconnect Immediately
**Setup**: Query with 10 relays, all are offline or reject connection
**Current Behavior**:
- Each relay: `catchError` → emits "EOSE"
- useReqTimeline: Sets `eoseReceived = true`
- ReqViewer: Shows "LIVE" indicator (green, pulsing)
- Connection count: 0/10
- User sees: "LIVE" with 0 connected relays
**Expected Behavior**: Show "ERROR" or "NO RELAYS" status
### Scenario 2: Slow Relays with Timeout
**Setup**: Query with relay that takes 15 seconds to respond
**Current Behavior**:
- After 10s: EOSE timeout fires → emits fake "EOSE"
- Relay still connected, might send more events later
- User sees: "LIVE" but relay is counted as "done"
**Expected Behavior**: Continue waiting or show "PARTIAL" status
### Scenario 3: Mixed Success/Failure
**Setup**: 30 relays, 10 succeed with EOSE, 15 disconnect, 5 timeout
**Current Behavior**:
- All 30 eventually emit "EOSE" (real or fake)
- Overall EOSE emitted
- Shows "LIVE" with 10/30 connected
- User can't tell which relays actually completed vs failed
**Expected Behavior**: Show per-relay status and overall "PARTIAL" indicator
### Scenario 4: Mid-Query Disconnection
**Setup**: Relay sends 50 events, then disconnects before EOSE
**Current Behavior**:
- Disconnection → `catchError` → fake "EOSE"
- Events are shown, looks like query completed successfully
- No indication that query was interrupted
**Expected Behavior**: Show warning that relay disconnected mid-query
### Scenario 5: Streaming Mode with Gradual Disconnections
**Setup**: Query in streaming mode, relays disconnect one by one
**Current Behavior**:
- Each disconnection → fake "EOSE"
- Eventually all relays have "EOSE"
- Shows "LIVE" with 0/30 connected (THE REPORTED BUG!)
**Expected Behavior**: Show "OFFLINE" or "NO ACTIVE RELAYS" when all disconnect
### Scenario 6: Single Relay Query
**Setup**: Query with explicit relay that doesn't respond
**Current Behavior**:
- After 10s timeout → fake "EOSE"
- Shows "CLOSED" (not streaming)
- No indication relay never responded
**Expected Behavior**: Show "TIMEOUT" or "NO RESPONSE" status
### Scenario 7: AUTH Required But Not Provided
**Setup**: Relay requires authentication, no account active
**Current Behavior**:
- Relay returns "auth-required" CLOSED message
- Caught and turned into "EOSE"
- Looks like query completed with no results
**Expected Behavior**: Show "AUTH REQUIRED" status
## State Machine Requirements
### Required States
**Query-Level States**:
- `DISCOVERING`: Selecting relays (NIP-65 outbox discovery)
- `CONNECTING`: Waiting for first relay to connect
- `LOADING`: At least one relay connected, waiting for initial EOSE
- `LIVE`: At least one relay streaming after EOSE
- `PARTIAL`: Some relays completed, some failed/disconnected
- `CLOSED`: All relays sent EOSE and closed (non-streaming)
- `FAILED`: All relays failed to connect or errored
- `TIMEOUT`: No relays responded within timeout
- `AUTH_REQUIRED`: Some/all relays require authentication
**Per-Relay States** (tracked separately):
- `PENDING`: Relay in list but not yet connected
- `CONNECTING`: Connection attempt in progress
- `CONNECTED`: WebSocket open, REQ sent
- `RECEIVING`: Events being received
- `EOSE_RECEIVED`: EOSE message received (still connected)
- `CLOSED`: Clean closure after EOSE
- `DISCONNECTED`: Unexpected disconnection
- `ERROR`: Connection error or protocol error
- `TIMEOUT`: No response within timeout
- `AUTH_REQUIRED`: Relay requires authentication
### State Transition Rules
**Query Level**:
```
DISCOVERING → CONNECTING (when relays selected)
CONNECTING → LOADING (when first relay connects)
CONNECTING → FAILED (when all relay connections fail, timeout)
LOADING → LIVE (when EOSE received, stream=true, >0 relays connected)
LOADING → PARTIAL (when some EOSE, some failed, stream=true)
LOADING → CLOSED (when all EOSE received, stream=false)
LOADING → FAILED (when all relays fail before EOSE)
LIVE → PARTIAL (when some relays disconnect)
LIVE → FAILED (when all relays disconnect)
PARTIAL → LIVE (when previously failed relays reconnect)
PARTIAL → FAILED (when remaining relays disconnect)
```
**Per-Relay** (tracked in RelayStateManager):
```
PENDING → CONNECTING (when connection initiated)
CONNECTING → CONNECTED (when WebSocket open, REQ sent)
CONNECTING → ERROR (when connection fails)
CONNECTING → TIMEOUT (when connection takes too long)
CONNECTED → RECEIVING (when first event received)
CONNECTED → EOSE_RECEIVED (when EOSE received, no prior events)
CONNECTED → ERROR (when connection lost)
RECEIVING → EOSE_RECEIVED (when EOSE received)
RECEIVING → DISCONNECTED (when connection lost before EOSE)
RECEIVING → ERROR (when protocol error)
EOSE_RECEIVED → CLOSED (when relay closes connection after EOSE)
EOSE_RECEIVED → DISCONNECTED (when relay keeps connection open in streaming)
```
## Data Requirements
### Information We Need But Don't Have
1. **Per-Relay EOSE Status**:
- Which relays sent real EOSE?
- Which relays disconnected without EOSE?
- Which relays timed out?
- Which relays are still streaming?
2. **Per-Relay Event Counts**:
- How many events did each relay send?
- Useful for showing progress and diagnosing issues
3. **Error Details**:
- Why did relay fail? (connection refused, timeout, protocol error, auth required)
- Currently lost in `catchError(() => of("EOSE"))`
4. **Timing Information**:
- When did relay connect?
- When did first event arrive?
- When did EOSE arrive?
- How long did query take per relay?
5. **Relay Health Context**:
- Is relay in RelayLiveness backoff state?
- Has relay been failing consistently?
- Should we even attempt connection?
### Information We Have But Don't Use
From **RelayStateManager** (`src/services/relay-state-manager.ts`):
-`connectionState`: "connected" | "connecting" | "disconnected" | "error"
-`lastConnected`, `lastDisconnected`: Timestamps
-`errors[]`: Array of error messages with types
-`stats.connectionsCount`: How many times relay connected
From **RelayLiveness** (`src/services/relay-liveness.ts`):
- ✅ Failure counts per relay
- ✅ Backoff states
- ✅ Last success/failure times
- ✅ Should prevent connection attempts to dead relays
**Problem**: useReqTimeline doesn't integrate with either of these!
## Nostr Protocol Semantics
### REQ Lifecycle (NIP-01)
1. Client sends: `["REQ", <subscription_id>, <filter1>, <filter2>, ...]`
2. Relay responds with zero or more: `["EVENT", <subscription_id>, <event>]`
3. Relay sends: `["EOSE", <subscription_id>]` when initial query complete
4. Client can keep subscription open for streaming
5. Client closes: `["CLOSE", <subscription_id>]`
6. Relay can close: `["CLOSED", <subscription_id>, <reason>]`
### EOSE Semantics
**What EOSE means**:
- ✅ "I have sent all stored events matching your filter"
- ✅ "Initial query phase is complete"
- ✅ Connection is still open (unless relay closes immediately after)
**What EOSE does NOT mean**:
- ❌ "No more events will be sent" (streaming continues)
- ❌ "Connection is closing"
- ❌ "Query was successful" (could have returned 0 events)
### CLOSED Semantics
**Why relays send CLOSED**:
- `auth-required`: AUTH event required before query
- `rate-limited`: Too many requests
- `error`: Generic error (parsing, internal, etc.)
- `invalid`: Filter validation failed
**Client should**:
- Distinguish CLOSED from EOSE
- Handle auth-required by prompting user
- Handle rate-limiting with backoff
- Show errors to user
## Applesauce Behavior Analysis
### Retry/Reconnect Logic
**relay.subscription()** options:
- `retries` (deprecated): Number of retry attempts
- `reconnect` (default: true, 10 retries): Retry on connection failures
- `resubscribe` (default: false): Resubscribe if relay sends CLOSED
**Current usage in useReqTimeline.ts**:
```typescript
pool.subscription(relays, filtersWithLimit, {
retries: 5,
reconnect: 5,
resubscribe: true,
eventStore,
});
```
**Behavior**:
- Retries connection failures up to 5 times
- Resubscribes if relay sends CLOSED (like auth-required)
- Uses exponential backoff (see `Relay.createReconnectTimer`)
**Issue**: All this retry logic happens inside applesauce, invisible to useReqTimeline. We can't show "RETRYING" status or retry count to user.
### Group Subscription Behavior
**relay.subscription()** in RelayGroup:
```javascript
subscription(filters, opts) {
return this.internalSubscription(
(relay) => relay.subscription(filters, opts),
opts?.eventStore == null ? identity : filterDuplicateEvents(opts?.eventStore)
);
}
```
**Key behaviors**:
1. Creates observable for each relay
2. Merges all observables
3. Deduplicates events via EventStore
4. Catches errors and converts to "EOSE" (THE BUG)
5. Emits overall "EOSE" when all relays done
**Missing**:
- No per-relay state tracking
- No way to query "which relays have sent EOSE?"
- No way to query "which relays are still connected?"
- Error information is lost
## Technical Constraints
### What We Can't Change
1. **Applesauce-relay library behavior**:
- We can't modify the `catchError(() => of("EOSE"))` in RelayGroup
- This is in node_modules, upstream library
- Would need to fork or submit PR
2. **Observable-based API**:
- pool.subscription returns `Observable<SubscriptionResponse>`
- Response is either `NostrEvent` or string `"EOSE"`
- Can't change this interface without forking
3. **Relay connection pooling**:
- RelayPool manages all relay connections globally
- Multiple components can share same relay connection
- Can't have per-query relay isolation
### What We Can Work With
1. **RelayStateManager**:
- Already tracks per-relay connection state
- Updates in real-time via observables
- Available via `useRelayState()` hook
- CAN BE ENHANCED to track per-query state
2. **EventStore**:
- Already receives all events
- Could be instrumented to track per-relay events
- Has access to relay URL via event metadata
3. **Custom observables**:
- We can tap into the subscription observable
- Track events and EOSE per relay ourselves
- Build parallel state tracking
4. **Relay URL in events**:
- Events marked with relay URL via `markFromRelay()` operator
- Can track which relay sent which events
## Proposed Solutions
### Solution 1: Per-Relay Subscription Tracking (Recommended)
**Approach**: Track individual relay subscriptions in parallel with the group subscription.
**Implementation**:
```typescript
interface RelaySubscriptionState {
url: string;
status: 'pending' | 'connecting' | 'receiving' | 'eose' | 'closed' | 'error';
eventCount: number;
firstEventAt?: number;
eoseAt?: number;
error?: Error;
}
function useReqTimelineEnhanced(id, filters, relays, options) {
const [relayStates, setRelayStates] = useState<Map<string, RelaySubscriptionState>>();
// Subscribe to individual relays
useEffect(() => {
const subs = relays.map(url => {
const relay = pool.relay(url);
return relay.req(filters).subscribe({
next: (response) => {
if (response === 'EOSE') {
setRelayStates(prev => prev.set(url, { ...prev.get(url), status: 'eose', eoseAt: Date.now() }));
} else {
setRelayStates(prev => prev.set(url, {
...prev.get(url),
status: 'receiving',
eventCount: (prev.get(url)?.eventCount ?? 0) + 1
}));
}
},
error: (err) => {
setRelayStates(prev => prev.set(url, { ...prev.get(url), status: 'error', error: err }));
}
});
});
return () => subs.forEach(sub => sub.unsubscribe());
}, [relays, filters]);
// Derive overall state from individual relay states
const overallState = useMemo(() => {
const states = Array.from(relayStates.values());
const connected = states.filter(s => ['receiving', 'eose'].includes(s.status));
const eose = states.filter(s => s.status === 'eose');
const errors = states.filter(s => s.status === 'error');
if (connected.length === 0 && errors.length === states.length) return 'FAILED';
if (eose.length === states.length) return 'CLOSED';
if (eose.length > 0 && connected.length > 0) return 'LIVE';
if (connected.length > 0) return 'LOADING';
return 'CONNECTING';
}, [relayStates]);
return { events, relayStates, overallState };
}
```
**Pros**:
- ✅ Accurate per-relay tracking
- ✅ Can distinguish real EOSE from errors
- ✅ Works around applesauce bug without forking
- ✅ Provides rich debugging information
**Cons**:
- ❌ Duplicate subscriptions (one per relay + one group)
- ❌ More memory usage
- ❌ Potential for state synchronization issues
### Solution 2: Enhanced Group Observable Wrapper
**Approach**: Wrap the group subscription and parse relay URL from event metadata.
**Implementation**:
```typescript
function useReqTimelineWithTracking(id, filters, relays, options) {
const [relayEose, setRelayEose] = useState<Set<string>>(new Set());
const { relays: relayStates } = useRelayState();
useEffect(() => {
const observable = pool.subscription(relays, filters, options).pipe(
tap(response => {
if (typeof response === 'string' && response === 'EOSE') {
// This is the aggregated EOSE, check which relays are still connected
const stillConnected = relays.filter(url =>
relayStates[url]?.connectionState === 'connected'
);
// If no relays connected, treat as failure not EOSE
if (stillConnected.length === 0) {
setError(new Error('All relays disconnected'));
return;
}
} else if (isNostrEvent(response)) {
// Track which relay sent this event
const relayUrl = (response as any)._relay; // Added by markFromRelay()
if (relayUrl && !relayEose.has(relayUrl)) {
// Mark relay as active/receiving
}
}
})
);
return observable.subscribe(/* ... */);
}, [relays, filters]);
}
```
**Pros**:
- ✅ Single subscription (no duplication)
- ✅ Uses existing infrastructure
- ✅ Leverages RelayStateManager
**Cons**:
- ❌ Can't distinguish real EOSE from fake (happens in applesauce)
- ❌ Relies on relay URL being added to events
- ❌ Still shows "EOSE" when all relays disconnect
### Solution 3: Fork Applesauce-Relay (Not Recommended)
**Approach**: Fork applesauce-relay and fix the catchError bug.
**Changes needed**:
```typescript
// In group.js, change:
catchError(() => of("EOSE"))
// To:
catchError((err) => of({ type: 'ERROR', relay, error: err }))
// And update EOSE aggregation to only count real EOSE
```
**Pros**:
- ✅ Fixes root cause
- ✅ Proper error handling
- ✅ Could be upstreamed
**Cons**:
- ❌ Maintenance burden of fork
- ❌ Need to track upstream changes
- ❌ Breaks applesauce API contract
### Solution 4: Hybrid Approach (RECOMMENDED)
**Combine** Solution 1 + Solution 2:
1. Use RelayStateManager to track connection state
2. Subscribe to group observable for events (deduplication)
3. Build per-relay state machine based on:
- Connection state from RelayStateManager
- Events received (tracked by relay URL in metadata)
- Overall EOSE from group subscription
4. Derive accurate overall state
**Implementation** in new file `src/hooks/useReqTimelineEnhanced.ts`:
```typescript
interface ReqRelayState {
url: string;
connectionState: 'pending' | 'connecting' | 'connected' | 'disconnected' | 'error';
subscriptionState: 'waiting' | 'receiving' | 'eose' | 'timeout' | 'error';
eventCount: number;
firstEventAt?: number;
lastEventAt?: number;
errorMessage?: string;
}
interface ReqOverallState {
status: 'discovering' | 'connecting' | 'loading' | 'live' | 'partial' | 'closed' | 'failed';
connectedCount: number;
eoseCount: number;
errorCount: number;
totalRelays: number;
}
export function useReqTimelineEnhanced(
id: string,
filters: Filter | Filter[],
relays: string[],
options: UseReqTimelineOptions = {}
) {
// State
const [relayStates, setRelayStates] = useState<Map<string, ReqRelayState>>(new Map());
const [overallEose, setOverallEose] = useState(false);
// Get relay connection states
const { relays: globalRelayStates } = useRelayState();
// Subscribe to events
const observable = pool.subscription(relays, filters, options);
useEffect(() => {
// Initialize relay states
setRelayStates(new Map(relays.map(url => [
url,
{
url,
connectionState: 'pending',
subscriptionState: 'waiting',
eventCount: 0,
}
])));
const sub = observable.subscribe({
next: (response) => {
if (response === 'EOSE') {
setOverallEose(true);
} else {
const event = response as NostrEvent;
const relayUrl = (event as any)._relay;
setRelayStates(prev => {
const state = prev.get(relayUrl);
if (!state) return prev;
const next = new Map(prev);
next.set(relayUrl, {
...state,
subscriptionState: 'receiving',
eventCount: state.eventCount + 1,
firstEventAt: state.firstEventAt ?? Date.now(),
lastEventAt: Date.now(),
});
return next;
});
}
},
error: (err) => {
// Overall subscription error
},
});
return () => sub.unsubscribe();
}, [relays, filters]);
// Sync connection state from RelayStateManager
useEffect(() => {
setRelayStates(prev => {
const next = new Map(prev);
for (const [url, state] of prev) {
const globalState = globalRelayStates[url];
if (globalState) {
next.set(url, {
...state,
connectionState: globalState.connectionState as any,
});
}
}
return next;
});
}, [globalRelayStates]);
// Derive overall state
const overallState: ReqOverallState = useMemo(() => {
const states = Array.from(relayStates.values());
const connected = states.filter(s => s.connectionState === 'connected');
const receivedData = states.filter(s => s.eventCount > 0);
const errors = states.filter(s => s.connectionState === 'error');
const status = (() => {
if (relays.length === 0) return 'discovering';
if (connected.length === 0 && errors.length === states.length) return 'failed';
if (connected.length === 0 && receivedData.length === 0) return 'connecting';
if (!overallEose) return 'loading';
if (connected.length === 0 && overallEose) return 'closed';
if (connected.length > 0 && overallEose && options.stream) return 'live';
if (connected.length < relays.length && overallEose) return 'partial';
return 'closed';
})();
return {
status,
connectedCount: connected.length,
eoseCount: states.filter(s => s.subscriptionState === 'eose').length,
errorCount: errors.length,
totalRelays: relays.length,
};
}, [relayStates, overallEose, relays.length, options.stream]);
return {
events,
relayStates,
overallState,
loading: !overallEose,
eoseReceived: overallEose,
};
}
```
**Pros**:
- ✅ No duplicate subscriptions
- ✅ Accurate connection tracking
- ✅ Rich per-relay information
- ✅ Works with existing infrastructure
- ✅ Can show "LIVE" only when relays actually connected
**Cons**:
- ❌ Can't distinguish real EOSE from timeout/error (upstream issue)
- ❌ More complex state management
- ❌ Depends on event metadata having relay URL
## Recommendation
**Implement Solution 4 (Hybrid Approach)** as the most pragmatic path forward:
1. Create `useReqTimelineEnhanced` hook with per-relay state tracking
2. Update ReqViewer to use enhanced hook
3. Improve status indicator logic to use overall state
4. Add per-relay status display in relay dropdown
5. Show accurate indicators for edge cases
**Future work**:
- Submit PR to applesauce-relay to fix catchError bug
- Add per-relay EOSE tracking to applesauce (upstream enhancement)
- Implement relay health scoring to avoid dead relays
## Implementation Priority
### Phase 1: Critical Fixes (Immediate)
1. Implement `useReqTimelineEnhanced` hook
2. Update ReqViewer status indicator logic
3. Add per-relay state display
4. Handle "all relays disconnected" case
### Phase 2: Enhanced UX (Next)
5. Add per-relay event counts
6. Show relay timing information
7. Add retry/reconnection indicators
8. Integrate with RelayLiveness for smarter relay selection
### Phase 3: Advanced Features (Future)
9. Partial EOSE indicator (some relays done, some still loading)
10. Relay performance metrics
11. Automatic relay ranking and selection
12. Query optimization suggestions
## Testing Strategy
### Unit Tests
- State machine transitions
- Edge case handling
- EOSE aggregation logic
### Integration Tests
- Real relay connections
- Timeout scenarios
- Mixed success/failure scenarios
### Manual Testing Scenarios
1. Query with all offline relays
2. Query with mixed offline/online
3. Query with slow relay (>10s response)
4. Mid-query disconnections
5. Streaming mode with gradual disconnections
6. Single relay queries
7. AUTH-required relays
8. Rate-limited relays
## Metrics to Track
### User-Visible
- Time to first event
- Time to EOSE per relay
- Events per relay
- Success/failure ratio
### Debug/Observability
- Relay response times
- Failure reasons
- Retry attempts
- Reconnection events
## Related Issues
- RelayLiveness not being checked before connection attempts
- No visual feedback during relay discovery phase
- No indication of AUTH requirements
- No rate limiting awareness
## References
- NIP-01: https://github.com/nostr-protocol/nips/blob/master/01.md
- Applesauce-relay docs: (internal node_modules)
- RelayStateManager: `src/services/relay-state-manager.ts`
- useReqTimeline: `src/hooks/useReqTimeline.ts`
- ReqViewer: `src/components/ReqViewer.tsx`

View File

@@ -14,11 +14,13 @@ import {
Search,
Code,
Loader2,
Mail,
Send,
Inbox,
Sparkles,
Link as LinkIcon,
Check,
} from "lucide-react";
import { Virtuoso } from "react-virtuoso";
import { useReqTimeline } from "@/hooks/useReqTimeline";
import { useReqTimelineEnhanced } from "@/hooks/useReqTimelineEnhanced";
import { useGrimoire } from "@/core/state";
import { useRelayState } from "@/hooks/useRelayState";
import { useOutboxRelays } from "@/hooks/useOutboxRelays";
@@ -69,6 +71,13 @@ import { useCopy } from "@/hooks/useCopy";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
import { normalizeRelayURL } from "@/lib/relay-url";
import {
getStatusText,
getStatusTooltip,
getStatusColor,
shouldAnimate,
} from "@/lib/req-state-machine";
import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { MemoizedCompactEventRow } from "./nostr/CompactEventRow";
@@ -702,7 +711,6 @@ export default function ReqViewer({
const {
relays: selectedRelays,
reasoning,
isOptimized,
phase: relaySelectionPhase,
} = useOutboxRelays(resolvedFilter, outboxOptions);
@@ -723,26 +731,34 @@ export default function ReqViewer({
return selectedRelays;
}, [relays, relaySelectionPhase, selectedRelays]);
// Get relay state for each relay and calculate connected count
const relayStatesForReq = useMemo(
() =>
finalRelays.map((url) => ({
url,
state: relayStates[url],
})),
[finalRelays, relayStates],
);
const connectedCount = relayStatesForReq.filter(
(r) => r.state?.connectionState === "connected",
).length;
// Normalize relay URLs for consistent lookups in relayStates
// RelayStateManager normalizes all URLs (adds trailing slash, lowercase, etc.)
// so we must normalize here too to match the keys in relayStates
const normalizedRelays = useMemo(() => {
return finalRelays.map((url) => {
try {
return normalizeRelayURL(url);
} catch (err) {
console.warn("Failed to normalize relay URL:", url, err);
return url; // Fallback to original URL if normalization fails
}
});
}, [finalRelays]);
// Streaming is the default behavior, closeOnEose inverts it
const stream = !closeOnEose;
const { events, loading, error, eoseReceived } = useReqTimeline(
const {
events,
loading,
error,
eoseReceived,
relayStates: reqRelayStates,
overallState,
} = useReqTimelineEnhanced(
`req-${JSON.stringify(filter)}-${closeOnEose}`,
resolvedFilter,
finalRelays,
normalizedRelays,
{ limit: resolvedFilter.limit || 50, stream },
);
@@ -915,48 +931,25 @@ export default function ReqViewer({
{/* Compact Header */}
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between">
{/* Left: Status Indicator */}
<div className="flex items-center gap-2">
<Radio
className={`size-3 ${
relaySelectionPhase !== "ready"
? "text-yellow-500 animate-pulse"
: loading && eoseReceived && stream
? "text-green-500 animate-pulse"
: loading && !eoseReceived
? "text-yellow-500 animate-pulse"
: eoseReceived
? "text-muted-foreground"
: "text-yellow-500 animate-pulse"
}`}
/>
<span
className={`${
relaySelectionPhase !== "ready"
? "text-yellow-500"
: loading && eoseReceived && stream
? "text-green-500"
: loading && !eoseReceived
? "text-yellow-500"
: eoseReceived
? "text-muted-foreground"
: "text-yellow-500"
} font-semibold`}
>
{relaySelectionPhase === "discovering"
? "DISCOVERING RELAYS"
: relaySelectionPhase === "selecting"
? "SELECTING RELAYS"
: loading && eoseReceived && stream
? "LIVE"
: loading && !eoseReceived && events.length === 0
? "CONNECTING"
: loading && !eoseReceived
? "LOADING"
: eoseReceived
? "CLOSED"
: "CONNECTING"}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 cursor-help">
<Radio
className={`size-3 ${getStatusColor(overallState.status)} ${
shouldAnimate(overallState.status) ? "animate-pulse" : ""
}`}
/>
<span
className={`${getStatusColor(overallState.status)} font-semibold`}
>
{getStatusText(overallState)}
</span>
</div>
</TooltipTrigger>
<TooltipContent className="bg-popover text-popover-foreground border border-border shadow-md">
<p>{getStatusTooltip(overallState)}</p>
</TooltipContent>
</Tooltip>
{/* Right: Stats */}
<div className="flex items-center gap-3">
@@ -991,72 +984,28 @@ export default function ReqViewer({
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
<Wifi className="size-3" />
<span>
{connectedCount}/{finalRelays.length}
{overallState.connectedCount}/{overallState.totalRelays}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-80 max-h-96 overflow-y-auto"
className="w-96 max-h-96 overflow-y-auto"
>
{/* Connection Status */}
<div className="py-1 border-b border-border">
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground">
Connection Status
</div>
{relayStatesForReq.map(({ url, state }) => {
const connIcon = getConnectionIcon(state);
const authIcon = getAuthIcon(state);
return (
<DropdownMenuItem
key={url}
className="flex items-center justify-between gap-2"
>
<RelayLink
url={url}
showInboxOutbox={false}
className="flex-1 min-w-0 hover:bg-transparent"
iconClassname="size-3"
urlClassname="text-xs"
/>
<div
className="flex items-center gap-1.5 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
{authIcon && (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{authIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{authIcon.label}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{connIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{connIcon.label}</p>
</TooltipContent>
</Tooltip>
</div>
</DropdownMenuItem>
);
})}
</div>
{/* Relay Selection */}
{!relays && reasoning && reasoning.length > 0 && (
<div className="py-2">
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground">
Relay Selection
{isOptimized && (
<span className="ml-1.5 font-normal">
(
{/* Header: Relay Selection Strategy */}
<div className="px-3 py-2 border-b border-border">
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
{relays ? (
// Explicit relays
<>
<LinkIcon className="size-3 text-muted-foreground/60" />
<span>Explicit Relays ({finalRelays.length})</span>
</>
) : reasoning && reasoning.some((r) => !r.isFallback) ? (
// NIP-65 Outbox
<>
<Sparkles className="size-3 text-muted-foreground/60" />
<span>
<button
className="text-accent underline decoration-dotted cursor-crosshair"
onClick={(e) => {
@@ -1064,48 +1013,206 @@ export default function ReqViewer({
addWindow("nip", { number: "65" });
}}
>
NIP-65
</button>
)
NIP-65 Outbox
</button>{" "}
({finalRelays.length} relays)
</span>
)}
</div>
{/* Flat list of relays with icons and counts */}
<div className="px-3 py-1 space-y-1">
{reasoning.map((r, i) => (
<div
key={i}
className="flex items-center gap-2 text-xs py-0.5"
>
<RelayLink
url={r.relay}
className="flex-1 truncate font-mono text-foreground/80"
/>
<div className="flex items-center gap-2 flex-shrink-0 text-muted-foreground">
{r.readers.length > 0 && (
<div className="flex items-center gap-0.5">
<Mail className="w-3 h-3" />
<span>{r.readers.length}</span>
</div>
)}
{r.writers.length > 0 && (
<div className="flex items-center gap-0.5">
<Send className="w-3 h-3" />
<span>{r.writers.length}</span>
</div>
)}
{r.isFallback && (
<span className="text-[10px] text-muted-foreground/60">
fallback
</span>
)}
</div>
</div>
))}
</div>
</>
) : (
// Fallback relays
<>
<Inbox className="size-3 text-muted-foreground/60" />
<span>Fallback Relays ({finalRelays.length})</span>
</>
)}
</div>
)}
</div>
{(() => {
// Group relays by connection status
// Use normalizedRelays for lookups to match RelayStateManager's keys
const onlineRelays: string[] = [];
const disconnectedRelays: string[] = [];
normalizedRelays.forEach((url) => {
const globalState = relayStates[url];
const isConnected =
globalState?.connectionState === "connected";
if (isConnected) {
onlineRelays.push(url);
} else {
disconnectedRelays.push(url);
}
});
const renderRelay = (url: string) => {
const globalState = relayStates[url];
const reqState = reqRelayStates.get(url);
const connIcon = getConnectionIcon(globalState);
const authIcon = getAuthIcon(globalState);
// Find NIP-65 info for this relay (if using outbox)
const nip65Info = reasoning?.find((r) => r.relay === url);
// Build comprehensive tooltip content
const tooltipContent = (
<div className="space-y-3 text-xs p-1">
<div className="font-mono font-bold border-b border-border pb-2 mb-2 break-all text-primary">
{url}
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Connection
</div>
<div className="flex items-center gap-1.5 font-medium">
<span className="shrink-0">{connIcon.icon}</span>
<span>{connIcon.label}</span>
</div>
</div>
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Authentication
</div>
<div className="flex items-center gap-1.5 font-medium">
<span className="shrink-0">{authIcon.icon}</span>
<span>{authIcon.label}</span>
</div>
</div>
{reqState && (
<>
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Subscription
</div>
<div className="font-medium capitalize">
{reqState.subscriptionState}
</div>
</div>
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Events
</div>
<div className="flex items-center gap-1.5 font-medium">
<FileText className="size-3 text-muted-foreground" />
<span>{reqState.eventCount} received</span>
</div>
</div>
</>
)}
{nip65Info && (
<>
{nip65Info.readers.length > 0 && (
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Inbox (Read)
</div>
<div className="font-medium">
{nip65Info.readers.length} author
{nip65Info.readers.length !== 1 ? "s" : ""}
</div>
</div>
)}
{nip65Info.writers.length > 0 && (
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Outbox (Write)
</div>
<div className="font-medium">
{nip65Info.writers.length} author
{nip65Info.writers.length !== 1 ? "s" : ""}
</div>
</div>
)}
</>
)}
</div>
</div>
);
return (
<Tooltip key={url}>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-xs py-1 px-3 hover:bg-accent/5 cursor-default">
{/* Relay URL */}
<RelayLink
url={url}
showInboxOutbox={false}
className="flex-1 min-w-0 truncate font-mono text-foreground/80"
/>
{/* Right side: compact status icons */}
<div className="flex items-center gap-1.5 flex-shrink-0">
{/* Event count badge */}
{reqState && reqState.eventCount > 0 && (
<div className="flex items-center gap-1 text-[10px] text-muted-foreground font-medium">
<FileText className="size-2.5" />
<span>{reqState.eventCount}</span>
</div>
)}
{/* EOSE status */}
{reqState && (
<>
{reqState.subscriptionState === "eose" ? (
<Check className="size-3 text-green-600/70" />
) : (
(reqState.subscriptionState === "receiving" ||
reqState.subscriptionState ===
"waiting") && (
<Loader2 className="size-3 text-muted-foreground/40 animate-spin" />
)
)}
</>
)}
{/* Auth icon (always visible) */}
<div>{authIcon.icon}</div>
{/* Connection icon (always visible) */}
<div>{connIcon.icon}</div>
</div>
</div>
</TooltipTrigger>
<TooltipContent
side="left"
className="max-w-xs bg-popover text-popover-foreground border border-border shadow-md"
>
{tooltipContent}
</TooltipContent>
</Tooltip>
);
};
return (
<>
{/* Online Section */}
{onlineRelays.length > 0 && (
<div className="py-2">
<div className="px-3 pb-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
Online ({onlineRelays.length})
</div>
{onlineRelays.map(renderRelay)}
</div>
)}
{/* Disconnected Section */}
{disconnectedRelays.length > 0 && (
<div className="py-2 border-t border-border">
<div className="px-3 pb-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
Disconnected ({disconnectedRelays.length})
</div>
{disconnectedRelays.map(renderRelay)}
</div>
)}
</>
);
})()}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -0,0 +1,340 @@
import { useState, useEffect, useMemo, useRef } from "react";
import pool from "@/services/relay-pool";
import type { NostrEvent, Filter } from "nostr-tools";
import { useEventStore } from "applesauce-react/hooks";
import { isNostrEvent } from "@/lib/type-guards";
import { useStableValue, useStableArray } from "./useStable";
import { useRelayState } from "./useRelayState";
import type { ReqRelayState, ReqOverallState } from "@/types/req-state";
import { deriveOverallState } from "@/lib/req-state-machine";
interface UseReqTimelineEnhancedOptions {
limit?: number;
stream?: boolean;
}
interface UseReqTimelineEnhancedReturn {
events: NostrEvent[];
loading: boolean;
error: Error | null;
eoseReceived: boolean;
// Enhanced state tracking
relayStates: Map<string, ReqRelayState>;
overallState: ReqOverallState;
}
/**
* Enhanced REQ timeline hook with per-relay state tracking
*
* This hook extends the original useReqTimeline with accurate per-relay
* state tracking and overall status derivation. It solves the "LIVE with 0 relays"
* bug by tracking connection state and event counts separately per relay.
*
* Architecture:
* - Uses pool.subscription() for event streaming (with deduplication)
* - Syncs connection state from RelayStateManager
* - Tracks events per relay via event._relay metadata
* - Derives overall state from individual relay states
*
* @param id - Unique identifier for this timeline (for caching)
* @param filters - Nostr filter(s)
* @param relays - Array of relay URLs
* @param options - Stream mode, limit, etc.
*/
export function useReqTimelineEnhanced(
id: string,
filters: Filter | Filter[],
relays: string[],
options: UseReqTimelineEnhancedOptions = { limit: 50 },
): UseReqTimelineEnhancedReturn {
const eventStore = useEventStore();
const { limit, stream = false } = options;
// Core state (compatible with original useReqTimeline)
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [eoseReceived, setEoseReceived] = useState(false);
const [eventsMap, setEventsMap] = useState<Map<string, NostrEvent>>(
new Map(),
);
// Enhanced: Per-relay state tracking
const [relayStates, setRelayStates] = useState<Map<string, ReqRelayState>>(
new Map(),
);
const queryStartedAt = useRef<number>(Date.now());
const eoseReceivedRef = useRef<boolean>(false);
// Keep ref in sync with state
useEffect(() => {
eoseReceivedRef.current = eoseReceived;
}, [eoseReceived]);
// Get global relay connection states from RelayStateManager
const { relays: globalRelayStates } = useRelayState();
// Sort events by created_at (newest first)
const events = useMemo(() => {
return Array.from(eventsMap.values()).sort(
(a, b) => b.created_at - a.created_at,
);
}, [eventsMap]);
// Stabilize inputs to prevent unnecessary re-renders
const stableFilters = useStableValue(filters);
const stableRelays = useStableArray(relays);
// Initialize relay states when relays change
useEffect(() => {
queryStartedAt.current = Date.now();
const initialStates = new Map<string, ReqRelayState>();
for (const url of relays) {
initialStates.set(url, {
url,
connectionState: "pending",
subscriptionState: "waiting",
eventCount: 0,
});
}
setRelayStates(initialStates);
}, [stableRelays]);
// Sync connection states from RelayStateManager
// This runs whenever globalRelayStates updates
useEffect(() => {
if (relays.length === 0) return;
setRelayStates((prev) => {
const next = new Map(prev);
let changed = false;
// Sync state for all relays in our query
for (const url of relays) {
const globalState = globalRelayStates[url];
const currentState = prev.get(url);
// Initialize if relay not in map yet (shouldn't happen, but defensive)
if (!currentState) {
next.set(url, {
url,
connectionState: globalState?.connectionState || "pending",
subscriptionState: "waiting",
eventCount: 0,
connectedAt: globalState?.lastConnected,
disconnectedAt: globalState?.lastDisconnected,
});
changed = true;
console.log(
"REQ Enhanced: Initialized missing relay state",
url,
globalState?.connectionState,
);
} else if (
globalState &&
globalState.connectionState !== currentState.connectionState
) {
// Update connection state if changed
next.set(url, {
...currentState,
connectionState: globalState.connectionState as any,
connectedAt: globalState.lastConnected,
disconnectedAt: globalState.lastDisconnected,
});
changed = true;
console.log(
"REQ Enhanced: Connection state changed",
url,
currentState.connectionState,
"→",
globalState.connectionState,
);
}
}
return changed ? next : prev;
});
}, [globalRelayStates, relays]);
// Subscribe to events
useEffect(() => {
if (relays.length === 0) {
setLoading(false);
return;
}
console.log("REQ Enhanced: Starting query", {
relays,
filters,
limit,
stream,
});
setLoading(true);
setError(null);
setEoseReceived(false);
setEventsMap(new Map());
// Normalize filters to array
const filterArray = Array.isArray(filters) ? filters : [filters];
// Add limit to filters if specified
const filtersWithLimit = filterArray.map((f) => ({
...f,
limit: limit || f.limit,
}));
// CRITICAL FIX: Subscribe to each relay INDIVIDUALLY to get per-relay EOSE
// Previously used pool.subscription() which only emits EOSE when ALL relays finish
// Now we track each relay separately for accurate per-relay EOSE detection
const subscriptions = relays.map((url) => {
const relay = pool.relay(url);
return relay
.subscription(filtersWithLimit, {
retries: 5,
reconnect: 5,
resubscribe: true,
})
.subscribe(
(response) => {
// Response can be an event or 'EOSE' string
if (typeof response === "string" && response === "EOSE") {
console.log("REQ Enhanced: EOSE received from", url);
// Mark THIS specific relay as having received EOSE
setRelayStates((prev) => {
const state = prev.get(url);
if (!state || state.subscriptionState === "eose") {
return prev; // No change needed
}
const next = new Map(prev);
next.set(url, {
...state,
subscriptionState: "eose",
eoseAt: Date.now(),
});
// Check if ALL relays have reached EOSE
const allEose = Array.from(next.values()).every(
(s) =>
s.subscriptionState === "eose" ||
s.connectionState === "error" ||
s.connectionState === "disconnected",
);
if (allEose && !eoseReceivedRef.current) {
console.log("REQ Enhanced: All relays finished");
setEoseReceived(true);
if (!stream) {
setLoading(false);
}
}
return next;
});
} else if (isNostrEvent(response)) {
// Event received - store and track per relay
const event = response as NostrEvent & { _relay?: string };
// Store in EventStore and local map
eventStore.add(event);
setEventsMap((prev) => {
const next = new Map(prev);
next.set(event.id, event);
return next;
});
// Update relay state for this specific relay
// Use url from subscription, not event._relay (which might be wrong)
setRelayStates((prev) => {
const state = prev.get(url);
const now = Date.now();
const next = new Map(prev);
if (!state) {
// Relay not in map - initialize it (defensive)
console.warn(
"REQ Enhanced: Event from unknown relay, initializing",
url,
);
next.set(url, {
url,
connectionState: "connected",
subscriptionState: "receiving",
eventCount: 1,
firstEventAt: now,
lastEventAt: now,
});
} else {
// Update existing relay state
next.set(url, {
...state,
subscriptionState: "receiving",
eventCount: state.eventCount + 1,
firstEventAt: state.firstEventAt ?? now,
lastEventAt: now,
});
}
return next;
});
} else {
console.warn(
"REQ Enhanced: Unexpected response type from",
url,
response,
);
}
},
(err: Error) => {
console.error("REQ Enhanced: Error from", url, err);
// Mark this relay as errored
setRelayStates((prev) => {
const state = prev.get(url);
if (!state) return prev;
const next = new Map(prev);
next.set(url, {
...state,
subscriptionState: "error",
errorMessage: err.message,
errorType: "connection",
});
return next;
});
},
() => {
// This relay's observable completed
console.log("REQ Enhanced: Relay completed", url);
},
);
});
// Cleanup: unsubscribe from all relays
return () => {
subscriptions.forEach((sub) => sub.unsubscribe());
};
}, [id, stableFilters, stableRelays, limit, stream, eventStore]);
// Derive overall state from individual relay states
const overallState = useMemo(() => {
return deriveOverallState(
relayStates,
eoseReceived,
stream,
queryStartedAt.current,
);
}, [relayStates, eoseReceived, stream]);
return {
events: events || [],
loading,
error,
eoseReceived,
relayStates,
overallState,
};
}

View File

@@ -24,19 +24,19 @@ export function getConnectionIcon(relay: RelayState | undefined) {
const iconMap = {
connected: {
icon: <Wifi className="size-3 text-green-500" />,
icon: <Wifi className="size-3 text-green-600/70" />,
label: "Connected",
},
connecting: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
icon: <Loader2 className="size-3 text-yellow-600/70 animate-spin" />,
label: "Connecting",
},
disconnected: {
icon: <WifiOff className="size-3 text-muted-foreground" />,
icon: <WifiOff className="size-3 text-muted-foreground/60" />,
label: "Disconnected",
},
error: {
icon: <XCircle className="size-3 text-red-500" />,
icon: <XCircle className="size-3 text-red-600/70" />,
label: "Connection Error",
},
};
@@ -45,37 +45,40 @@ export function getConnectionIcon(relay: RelayState | undefined) {
/**
* Get authentication icon and label for a relay state
* Returns null if no authentication is required
* Always returns an icon (including for unauthenticated relays)
*/
export function getAuthIcon(relay: RelayState | undefined) {
if (!relay || relay.authStatus === "none") {
return null;
if (!relay) {
return {
icon: <Shield className="size-3 text-muted-foreground/40" />,
label: "Unknown",
};
}
const iconMap = {
authenticated: {
icon: <ShieldCheck className="size-3 text-green-500" />,
icon: <ShieldCheck className="size-3 text-green-600/70" />,
label: "Authenticated",
},
challenge_received: {
icon: <ShieldQuestion className="size-3 text-yellow-500" />,
icon: <ShieldQuestion className="size-3 text-yellow-600/70" />,
label: "Challenge Received",
},
authenticating: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
icon: <Loader2 className="size-3 text-yellow-600/70 animate-spin" />,
label: "Authenticating",
},
failed: {
icon: <ShieldX className="size-3 text-red-500" />,
icon: <ShieldX className="size-3 text-red-600/70" />,
label: "Authentication Failed",
},
rejected: {
icon: <ShieldAlert className="size-3 text-muted-foreground" />,
icon: <ShieldAlert className="size-3 text-muted-foreground/60" />,
label: "Authentication Rejected",
},
none: {
icon: <Shield className="size-3 text-muted-foreground" />,
label: "No Authentication",
icon: <Shield className="size-3 text-muted-foreground/40" />,
label: "Not required",
},
};
return iconMap[relay.authStatus] || iconMap.none;

View File

@@ -0,0 +1,681 @@
import { describe, it, expect } from "vitest";
import {
deriveOverallState,
getStatusText,
getStatusTooltip,
getStatusColor,
shouldAnimate,
getRelayStateBadge,
} from "./req-state-machine";
import type { ReqRelayState } from "@/types/req-state";
describe("deriveOverallState", () => {
const queryStartedAt = Date.now();
describe("discovering state", () => {
it("should return discovering when no relays", () => {
const state = deriveOverallState(new Map(), false, false, queryStartedAt);
expect(state.status).toBe("discovering");
expect(state.totalRelays).toBe(0);
});
});
describe("connecting state", () => {
it("should return connecting when relays pending with no events", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "pending",
subscriptionState: "waiting",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("connecting");
expect(state.hasReceivedEvents).toBe(false);
expect(state.hasActiveRelays).toBe(false);
});
it("should return connecting when relays connecting with no events", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connecting",
subscriptionState: "waiting",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("connecting");
});
});
describe("failed state", () => {
it("should return failed when all relays error with no events", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("failed");
expect(state.allRelaysFailed).toBe(true);
expect(state.errorCount).toBe(2);
});
});
describe("loading state", () => {
it("should return loading when connected but no EOSE", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "receiving",
eventCount: 5,
firstEventAt: Date.now(),
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("loading");
expect(state.hasReceivedEvents).toBe(true);
expect(state.hasActiveRelays).toBe(true);
expect(state.receivingCount).toBe(1);
});
it("should return loading when waiting for events", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "waiting",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("loading");
expect(state.hasReceivedEvents).toBe(false);
expect(state.connectedCount).toBe(1);
});
});
describe("live state", () => {
it("should return live when EOSE + streaming + connected", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
eoseAt: Date.now(),
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("live");
expect(state.hasActiveRelays).toBe(true);
expect(state.eoseCount).toBe(1);
});
it("should return live with multiple connected relays", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "connected",
subscriptionState: "receiving",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("live");
expect(state.connectedCount).toBe(2);
});
});
describe("offline state", () => {
it("should return offline when all disconnected after EOSE in streaming", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("offline");
expect(state.hasActiveRelays).toBe(false);
expect(state.hasReceivedEvents).toBe(true);
expect(state.disconnectedCount).toBe(2);
});
it("should return offline when all errored after EOSE in streaming", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "error",
subscriptionState: "eose",
eventCount: 10,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("offline");
});
});
describe("partial state", () => {
it("should return partial when some relays ok, some failed after EOSE", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("partial");
expect(state.connectedCount).toBe(1);
expect(state.errorCount).toBe(1);
});
it("should return partial when some disconnected after EOSE", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("partial");
expect(state.disconnectedCount).toBe(1);
});
});
describe("closed state", () => {
it("should return closed when EOSE + not streaming", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 10,
},
],
]);
const state = deriveOverallState(relays, true, false, queryStartedAt);
expect(state.status).toBe("closed");
});
it("should return closed when all relays disconnected after EOSE non-streaming", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, true, false, queryStartedAt);
expect(state.status).toBe("closed");
});
});
describe("edge cases from analysis", () => {
it("Scenario 1: All relays disconnect immediately", () => {
const relays = new Map<string, ReqRelayState>();
for (let i = 0; i < 10; i++) {
relays.set(`wss://relay${i}.com`, {
url: `wss://relay${i}.com`,
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
});
}
const state = deriveOverallState(relays, false, true, queryStartedAt);
expect(state.status).toBe("failed");
expect(state.allRelaysFailed).toBe(true);
});
it("Scenario 5: Streaming mode with gradual disconnections (THE BUG)", () => {
// Start with all relays connected and receiving
const relays = new Map<string, ReqRelayState>();
for (let i = 0; i < 30; i++) {
relays.set(`wss://relay${i}.com`, {
url: `wss://relay${i}.com`,
connectionState: "disconnected", // All disconnected
subscriptionState: "eose",
eventCount: 5, // Had events before
});
}
const state = deriveOverallState(relays, true, true, queryStartedAt);
// Should be OFFLINE not LIVE
expect(state.status).toBe("offline");
expect(state.connectedCount).toBe(0);
expect(state.totalRelays).toBe(30);
expect(state.hasReceivedEvents).toBe(true);
});
it("Scenario 3: Mixed success/failure", () => {
const relays = new Map<string, ReqRelayState>();
// 10 succeed with EOSE
for (let i = 0; i < 10; i++) {
relays.set(`wss://success${i}.com`, {
url: `wss://success${i}.com`,
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
});
}
// 15 disconnect
for (let i = 0; i < 15; i++) {
relays.set(`wss://disconnect${i}.com`, {
url: `wss://disconnect${i}.com`,
connectionState: "disconnected",
subscriptionState: "waiting",
eventCount: 0,
});
}
// 5 error
for (let i = 0; i < 5; i++) {
relays.set(`wss://error${i}.com`, {
url: `wss://error${i}.com`,
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
});
}
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("partial");
expect(state.totalRelays).toBe(30);
expect(state.connectedCount).toBe(10);
expect(state.disconnectedCount).toBe(15);
expect(state.errorCount).toBe(5);
});
it("NEW: All relays disconnect before EOSE, no events (streaming)", () => {
// THE CRITICAL BUG: Stuck in LOADING when all relays disconnect
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "waiting", // Never got to receiving/eose
eventCount: 0,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "waiting",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, true, queryStartedAt);
// Should be FAILED, not LOADING
expect(state.status).toBe("failed");
expect(state.connectedCount).toBe(0);
expect(state.hasReceivedEvents).toBe(false);
});
it("NEW: All relays disconnect before EOSE, with events (streaming)", () => {
// Relays sent some events then disconnected before EOSE
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "receiving", // Was receiving
eventCount: 5,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "receiving",
eventCount: 3,
},
],
]);
const state = deriveOverallState(relays, false, true, queryStartedAt);
// Should be OFFLINE (had events but all disconnected)
expect(state.status).toBe("offline");
expect(state.connectedCount).toBe(0);
expect(state.hasReceivedEvents).toBe(true);
});
it("NEW: All relays disconnect before EOSE, with events (non-streaming)", () => {
// Same as above but non-streaming
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "receiving",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
// Should be CLOSED (non-streaming completes)
expect(state.status).toBe("closed");
expect(state.hasReceivedEvents).toBe(true);
});
it("NEW: Some relays EOSE, others disconnect before EOSE", () => {
// Partial success before overall EOSE
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "receiving",
eventCount: 3,
},
],
[
"wss://relay3.com",
{
url: "wss://relay3.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, true, queryStartedAt);
// Should be PARTIAL (some succeeded, some failed, but not all terminal)
expect(state.status).toBe("partial");
expect(state.connectedCount).toBe(1);
expect(state.eoseCount).toBe(1);
});
it("NEW: Mix of EOSE and errors, all terminal", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, true, queryStartedAt);
// All terminal (eose + error), should be PARTIAL
expect(state.status).toBe("partial");
expect(state.connectedCount).toBe(1);
});
});
});
describe("getStatusText", () => {
const baseState = {
totalRelays: 5,
connectedCount: 3,
receivingCount: 2,
eoseCount: 1,
errorCount: 0,
disconnectedCount: 0,
hasReceivedEvents: true,
hasActiveRelays: true,
allRelaysFailed: false,
queryStartedAt: Date.now(),
};
it("should return correct text for each status", () => {
expect(getStatusText({ ...baseState, status: "discovering" })).toBe(
"DISCOVERING",
);
expect(getStatusText({ ...baseState, status: "connecting" })).toBe(
"CONNECTING",
);
expect(getStatusText({ ...baseState, status: "loading" })).toBe("LOADING");
expect(getStatusText({ ...baseState, status: "live" })).toBe("LIVE");
expect(getStatusText({ ...baseState, status: "partial" })).toBe("PARTIAL");
expect(getStatusText({ ...baseState, status: "offline" })).toBe("OFFLINE");
expect(getStatusText({ ...baseState, status: "closed" })).toBe("CLOSED");
expect(getStatusText({ ...baseState, status: "failed" })).toBe("FAILED");
});
});
describe("getStatusTooltip", () => {
const baseState = {
totalRelays: 5,
connectedCount: 3,
receivingCount: 2,
eoseCount: 1,
errorCount: 0,
disconnectedCount: 0,
hasReceivedEvents: true,
hasActiveRelays: true,
allRelaysFailed: false,
queryStartedAt: Date.now(),
};
it("should provide detailed tooltips", () => {
const discovering = getStatusTooltip({
...baseState,
status: "discovering",
});
expect(discovering).toContain("NIP-65");
const loading = getStatusTooltip({ ...baseState, status: "loading" });
expect(loading).toContain("3/5");
const live = getStatusTooltip({ ...baseState, status: "live" });
expect(live).toContain("Streaming");
expect(live).toContain("3/5");
const offline = getStatusTooltip({ ...baseState, status: "offline" });
expect(offline).toContain("disconnected");
});
});
describe("getStatusColor", () => {
it("should return correct colors for each status", () => {
expect(getStatusColor("discovering")).toBe("text-yellow-500");
expect(getStatusColor("connecting")).toBe("text-yellow-500");
expect(getStatusColor("loading")).toBe("text-yellow-500");
expect(getStatusColor("live")).toBe("text-green-500");
expect(getStatusColor("partial")).toBe("text-yellow-500");
expect(getStatusColor("closed")).toBe("text-muted-foreground");
expect(getStatusColor("offline")).toBe("text-red-500");
expect(getStatusColor("failed")).toBe("text-red-500");
});
});
describe("shouldAnimate", () => {
it("should animate active states", () => {
expect(shouldAnimate("discovering")).toBe(true);
expect(shouldAnimate("connecting")).toBe(true);
expect(shouldAnimate("loading")).toBe(true);
expect(shouldAnimate("live")).toBe(true);
});
it("should not animate terminal states", () => {
expect(shouldAnimate("partial")).toBe(false);
expect(shouldAnimate("closed")).toBe(false);
expect(shouldAnimate("offline")).toBe(false);
expect(shouldAnimate("failed")).toBe(false);
});
});
describe("getRelayStateBadge", () => {
it("should return receiving badge", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "connected",
subscriptionState: "receiving",
eventCount: 5,
});
expect(badge?.text).toBe("RECEIVING");
expect(badge?.color).toBe("text-green-500");
});
it("should return eose badge", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
});
expect(badge?.text).toBe("EOSE");
expect(badge?.color).toBe("text-blue-500");
});
it("should return error badge", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
});
expect(badge?.text).toBe("ERROR");
expect(badge?.color).toBe("text-red-500");
});
it("should return offline badge for disconnected", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "disconnected",
subscriptionState: "waiting",
eventCount: 0,
});
expect(badge?.text).toBe("OFFLINE");
expect(badge?.color).toBe("text-muted-foreground");
});
it("should return null for connected waiting state", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "connected",
subscriptionState: "waiting",
eventCount: 0,
});
expect(badge).toBeNull();
});
});

View File

@@ -0,0 +1,268 @@
import type {
ReqRelayState,
ReqOverallState,
ReqOverallStatus,
} from "@/types/req-state";
/**
* Derive overall query status from individual relay states
*
* This function implements the core state machine logic that determines
* the overall status of a REQ subscription based on the states of individual
* relays. It handles edge cases like all-relays-disconnected, partial failures,
* and distinguishes between CLOSED and OFFLINE states.
*
* @param relayStates - Map of relay URLs to their current states
* @param overallEoseReceived - Whether the group subscription emitted EOSE
* @param isStreaming - Whether this is a streaming subscription (stream=true)
* @param queryStartedAt - Timestamp when the query started
* @returns Aggregated state for the entire query
*/
export function deriveOverallState(
relayStates: Map<string, ReqRelayState>,
overallEoseReceived: boolean,
isStreaming: boolean,
queryStartedAt: number,
): ReqOverallState {
const states = Array.from(relayStates.values());
// Count relay states
const totalRelays = states.length;
const connectedCount = states.filter(
(s) => s.connectionState === "connected",
).length;
const receivingCount = states.filter(
(s) => s.subscriptionState === "receiving",
).length;
const eoseCount = states.filter((s) => s.subscriptionState === "eose").length;
const errorCount = states.filter((s) => s.connectionState === "error").length;
const disconnectedCount = states.filter(
(s) => s.connectionState === "disconnected",
).length;
// Calculate flags
const hasReceivedEvents = states.some((s) => s.eventCount > 0);
const hasActiveRelays = connectedCount > 0;
const allRelaysFailed = totalRelays > 0 && errorCount === totalRelays;
const allDisconnected =
totalRelays > 0 && disconnectedCount + errorCount === totalRelays;
// Timing
const firstEventAt = states
.map((s) => s.firstEventAt)
.filter((t): t is number => t !== undefined)
.sort((a, b) => a - b)[0];
const allEoseAt = overallEoseReceived ? Date.now() : undefined;
// Check if all relays are in terminal states (won't make further progress)
const allRelaysTerminal = states.every(
(s) =>
s.subscriptionState === "eose" ||
s.connectionState === "error" ||
s.connectionState === "disconnected",
);
// Derive status based on relay states and flags
const status: ReqOverallStatus = (() => {
// No relays selected yet (NIP-65 discovery in progress)
if (totalRelays === 0) {
return "discovering";
}
// All relays failed to connect, no events received
if (allRelaysFailed && !hasReceivedEvents) {
return "failed";
}
// All relays are in terminal states (done trying)
// This handles the case where relays disconnect before EOSE
if (allRelaysTerminal && !overallEoseReceived) {
if (!hasReceivedEvents) {
// All relays gave up before sending events
return "failed";
}
if (!hasActiveRelays) {
// Received events but all relays disconnected before EOSE
if (isStreaming) {
return "offline"; // Was trying to stream, now offline
} else {
return "closed"; // Non-streaming query, relays closed
}
}
// Some relays still active but all others terminated
// This is a partial success scenario
return "partial";
}
// No relays connected and no events received yet
if (!hasActiveRelays && !hasReceivedEvents) {
return "connecting";
}
// Had events and EOSE, but all relays disconnected now
if (allDisconnected && hasReceivedEvents && overallEoseReceived) {
if (isStreaming) {
return "offline"; // Was live, now offline
} else {
return "closed"; // Completed and closed (expected)
}
}
// EOSE not received yet, still loading initial data
if (!overallEoseReceived) {
return "loading";
}
// EOSE received, but some relays have issues (check this before "live")
if (overallEoseReceived && (errorCount > 0 || disconnectedCount > 0)) {
if (hasActiveRelays) {
return "partial"; // Some working, some not
} else {
return "offline"; // All disconnected after EOSE
}
}
// EOSE received, streaming mode, all relays healthy and connected
if (overallEoseReceived && isStreaming && hasActiveRelays) {
return "live";
}
// EOSE received, not streaming, all done
if (overallEoseReceived && !isStreaming) {
return "closed";
}
// Default fallback (should rarely hit this)
return "loading";
})();
return {
status,
totalRelays,
connectedCount,
receivingCount,
eoseCount,
errorCount,
disconnectedCount,
hasReceivedEvents,
hasActiveRelays,
allRelaysFailed,
queryStartedAt,
firstEventAt,
allEoseAt,
};
}
/**
* Get user-friendly status text for display
*/
export function getStatusText(state: ReqOverallState): string {
switch (state.status) {
case "discovering":
return "DISCOVERING";
case "connecting":
return "CONNECTING";
case "loading":
return "LOADING";
case "live":
return "LIVE";
case "partial":
return "PARTIAL";
case "offline":
return "OFFLINE";
case "closed":
return "CLOSED";
case "failed":
return "FAILED";
}
}
/**
* Get detailed status description for tooltips
*/
export function getStatusTooltip(state: ReqOverallState): string {
const { status, connectedCount, totalRelays, hasReceivedEvents } = state;
switch (status) {
case "discovering":
return "Selecting optimal relays using NIP-65";
case "connecting":
return `Connecting to ${totalRelays} relay${totalRelays !== 1 ? "s" : ""}...`;
case "loading":
return hasReceivedEvents
? `Loading events from ${connectedCount}/${totalRelays} relays`
: `Waiting for events from ${connectedCount}/${totalRelays} relays`;
case "live":
return `Streaming live events from ${connectedCount}/${totalRelays} relays`;
case "partial":
return `${connectedCount}/${totalRelays} relays active, some failed or disconnected`;
case "offline":
return "All relays disconnected. Showing cached results.";
case "closed":
return "Query completed, all relays closed";
case "failed":
return `Failed to connect to any of ${totalRelays} relays`;
}
}
/**
* Get status indicator color class
*/
export function getStatusColor(status: ReqOverallStatus): string {
switch (status) {
case "discovering":
case "connecting":
case "loading":
return "text-yellow-500";
case "live":
return "text-green-500";
case "partial":
return "text-yellow-500";
case "closed":
return "text-muted-foreground";
case "offline":
case "failed":
return "text-red-500";
}
}
/**
* Should the status indicator pulse/animate?
*/
export function shouldAnimate(status: ReqOverallStatus): boolean {
return ["discovering", "connecting", "loading", "live"].includes(status);
}
/**
* Get relay subscription state badge text
*/
export function getRelayStateBadge(
relay: ReqRelayState,
): { text: string; color: string } | null {
const { subscriptionState, connectionState } = relay;
// Prioritize subscription state
if (subscriptionState === "receiving") {
return { text: "RECEIVING", color: "text-green-500" };
}
if (subscriptionState === "eose") {
return { text: "EOSE", color: "text-blue-500" };
}
if (subscriptionState === "error") {
return { text: "ERROR", color: "text-red-500" };
}
// Show connection state if not connected
if (connectionState === "connecting") {
return { text: "CONNECTING", color: "text-yellow-500" };
}
if (connectionState === "error") {
return { text: "ERROR", color: "text-red-500" };
}
if (connectionState === "disconnected") {
return { text: "OFFLINE", color: "text-muted-foreground" };
}
return null;
}

View File

@@ -52,11 +52,12 @@ function extractRelayContext(event: NostrEvent): {
}
// Aggregator relays for better event discovery
// IMPORTANT: URLs must be normalized (trailing slash, lowercase) to match RelayStateManager keys
export const AGGREGATOR_RELAYS = [
"wss://relay.nostr.band",
"wss://nos.lol",
"wss://purplepag.es",
"wss://relay.primal.net",
"wss://relay.nostr.band/",
"wss://nos.lol/",
"wss://purplepag.es/",
"wss://relay.primal.net/",
];
// Base event loader (used internally)

91
src/types/req-state.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Types for REQ subscription state tracking
*
* Provides per-relay and overall state for REQ subscriptions to enable
* accurate status indicators that distinguish between EOSE, disconnection,
* timeout, and error states.
*/
/**
* Connection state from RelayStateManager
*/
export type RelayConnectionState =
| "pending" // Not yet attempted
| "connecting" // Connection in progress
| "connected" // WebSocket connected
| "disconnected" // Disconnected (expected or unexpected)
| "error"; // Connection error
/**
* Subscription state specific to this REQ
*/
export type RelaySubscriptionState =
| "waiting" // Connected but no events yet
| "receiving" // Events being received
| "eose" // EOSE received (real or timeout)
| "error"; // Subscription error
/**
* Per-relay state for a single REQ subscription
*/
export interface ReqRelayState {
url: string;
// Connection state (from RelayStateManager)
connectionState: RelayConnectionState;
// Subscription state (tracked by enhanced hook)
subscriptionState: RelaySubscriptionState;
// Event tracking
eventCount: number;
firstEventAt?: number;
lastEventAt?: number;
// Timing
connectedAt?: number;
eoseAt?: number;
disconnectedAt?: number;
// Error handling
errorMessage?: string;
errorType?: "connection" | "protocol" | "timeout" | "auth";
}
/**
* Overall query state derived from individual relay states
*/
export type ReqOverallStatus =
| "discovering" // Selecting relays (NIP-65)
| "connecting" // Waiting for first relay to connect
| "loading" // Loading initial events
| "live" // Streaming after EOSE, relays connected
| "partial" // Some relays ok, some failed
| "closed" // All relays completed and closed
| "failed" // All relays failed
| "offline"; // All relays disconnected after being live
/**
* Aggregated state for the entire query
*/
export interface ReqOverallState {
status: ReqOverallStatus;
// Relay counts
totalRelays: number;
connectedCount: number;
receivingCount: number;
eoseCount: number;
errorCount: number;
disconnectedCount: number;
// Timing
queryStartedAt: number;
firstEventAt?: number;
allEoseAt?: number;
// Flags
hasReceivedEvents: boolean;
hasActiveRelays: boolean;
allRelaysFailed: boolean;
}