mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 06:57:07 +02:00
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:
@@ -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
|
||||
|
||||
1070
docs/req-viewer-improvement-plan.md
Normal file
1070
docs/req-viewer-improvement-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
787
docs/req-viewer-state-analysis.md
Normal file
787
docs/req-viewer-state-analysis.md
Normal 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`
|
||||
@@ -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>
|
||||
|
||||
|
||||
340
src/hooks/useReqTimelineEnhanced.ts
Normal file
340
src/hooks/useReqTimelineEnhanced.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
681
src/lib/req-state-machine.test.ts
Normal file
681
src/lib/req-state-machine.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
268
src/lib/req-state-machine.ts
Normal file
268
src/lib/req-state-machine.ts
Normal 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;
|
||||
}
|
||||
@@ -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
91
src/types/req-state.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user