Compare commits

...

5 Commits

Author SHA1 Message Date
Laan Tungir
04405da933 Fix feed page dropped posts: out-of-order events and race condition
prependPostCard() now inserts posts in the correct sorted position within
the existing DOM instead of falling back to a full renderFeed() rebuild
when a post is older than the first visible post. This handles relays
that deliver events out of order without wiping the feed.

Also fixed a race condition in fetchFeedWindow: the fire-and-forget
ndkFetchEvents callback was calling renderFeed() after bootstrap
completed, which wiped prepended live posts and caused concurrent
rebuild races. Now it only calls renderFeed() if the feed hasn't been
rendered yet (renderedPostIds.size === 0).

Same prependPostCard sorted-insert fix applied to post.html.
2026-07-01 09:22:33 -04:00
Laan Tungir
ee0077063d Implement append-only rendering for feed and post pages (task 1.1)
Split renderFeed() into full rebuild + prependPostCard() on both feed.html
and post.html. New live events now prepend a single card at the top of the
feed without rebuilding existing cards, eliminating O(n) DOM rebuilds on
every incoming event. DOM is trimmed to displayCount on overflow with
renderedPostIds kept in sync. Full rebuild path preserved for See More,
mute removal, bootstrap completion, and event mode.

Also added implementation checklist to plans/long-running-memory-bloat-fix.md.
2026-07-01 07:59:39 -04:00
Laan Tungir
b09bf0d839 Fix ReferenceError: move publishStartTime to outer scope so silent failure detection can access it 2026-06-30 11:36:36 -04:00
Laan Tungir
d04844cce8 Detect silent relay failures: after each batch, mark relays with no response as timed out (catches DNS errors, connection refused, HTTP handshake failures that NDK doesn't emit relay:publish:failed for) 2026-06-30 11:24:45 -04:00
Laan Tungir
b03b848909 Add debug logging: per-relay publish/timeout/reject with elapsed time, batch start/end timing 2026-06-30 11:07:27 -04:00
10 changed files with 6114 additions and 7 deletions

View File

@@ -0,0 +1,504 @@
# Long-Running Memory Bloat & Unresponsiveness Fix Plan
## Problem Statement
When the client runs for extended periods, pages become bloated and unresponsive. The root causes fall into four categories:
1. **Unbounded in-memory growth** — arrays/Maps/Sets that accumulate events without eviction
2. **Listener/event accumulation** — DOM listeners and worker subscriptions that are never cleaned up
3. **Over-aggressive polling** — 1-second intervals on every page, each triggering multiple worker round-trips
4. **Full re-renders**`innerHTML = ''` + rebuild on every incoming event, discarding DOM and re-attaching all listeners
The SharedWorker broadcasts every subscription event to every connected tab, and pages process all of them regardless of which subscription they originated from. Combined with full-DOM-rebuild rendering, this causes O(n) work on every event and linear memory growth over the session lifetime.
## Scope
**Pages affected:** `index.html`, `feed.html`, `post.html`, `notifications.html`, `relays.html`
**Worker affected:** `ndk-worker.js`
**Shared module affected:** `js/init-ndk.mjs`
## Architecture Overview
```mermaid
flowchart LR
subgraph Worker[ndk-worker.js SharedWorker]
sub[activeSubscriptions Map]
stats[relayActivityStats Map]
ports[connectedPorts array]
wallet[lightwalletTokenEvents Map]
broadcast[broadcast to all ports]
end
subgraph Tabs[Open Tabs - each gets ALL broadcasts]
index[index.html]
feed[feed.html]
post[post.html]
notif[notifications.html]
relays[relays.html]
end
sub -->|event/eose per sub| broadcast
stats -->|relayActivity per read/write| broadcast
broadcast -->|unfiltered| index
broadcast -->|unfiltered| feed
broadcast -->|unfiltered| post
broadcast -->|unfiltered| notif
broadcast -->|unfiltered| relays
feed -->|posts array grows forever| MemLeak[Memory Leak]
notif -->|notificationsById grows forever| MemLeak
notif -->|document.click leaked per row| ListenerLeak[Listener Leak]
feed -->|innerHTML rebuild per event| CPUSpike[CPU Spike]
relays -->|table rebuild every 5s| CPUSpike
```
## Implementation Phases
### Phase 1: P0 — Critical Rendering & Listener Leaks
**Goal:** Eliminate the two most severe causes of user-visible jank and memory growth.
#### 1.1 Append-only rendering for feed and post pages
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html)
**Current behavior:** [`renderFeed()`](www/feed.html:460) does `divFeed.innerHTML = ''` and rebuilds all cards on every incoming event.
**Changes:**
- Split `renderFeed()` into two functions:
- `renderFeed()` — full rebuild, only called on initial load, "See More", sort changes, or mute removal
- `prependPostCard(post)` — creates a single card, inserts it at the top of `divFeed` if newer than the first visible post, and wires interactions for just that one card
- In the `ndkEvent` handler, call `prependPostCard(evt)` instead of `renderFeed()` when a new kind-1 event arrives and the feed is already rendered
- Cap the visible DOM: if `divFeed.children.length > displayCount + SEE_MORE_INCREMENT`, remove the last child
- Keep `posts[]` sorted by inserting new events in the correct position (binary insert or simple unshift + periodic sort)
**Acceptance criteria:**
- New posts appear at the top without rebuilding existing cards
- Existing images/videos are not reloaded when a new post arrives
- "See More" still does a full re-render to show additional posts
#### 1.2 Append-only rendering for notifications page
**Files:** [`www/notifications.html`](www/notifications.html)
**Current behavior:** [`renderNotifications()`](www/notifications.html:886) does `divNotifications.innerHTML = ''` and rebuilds all rows on every new notification.
**Changes:**
- Split into `renderNotifications()` (full rebuild for filter changes / "View more") and `prependNotificationRow(item)` (single new row)
- In `addNotificationFromEvent`, call `prependNotificationRow` when the notifications list is already rendered and the new item passes the filter
- Cap visible DOM to `visibleNotificationsCount + NOTIFICATIONS_PAGE_SIZE`
**Acceptance criteria:**
- New notifications appear without rebuilding existing rows
- Filter toggles and "View more" still do full re-renders
#### 1.3 Fix notification `document.click` listener leak
**Files:** [`www/notifications.html`](www/notifications.html)
**Current behavior:** [`renderNotifications()`](www/notifications.html:1046) registers a `document.addEventListener('click', ...)` for **every** notification row, and these are never removed.
**Changes:**
- Remove the per-row `document.addEventListener('click', ...)` entirely
- Register a **single** document-level click listener in `main()` that closes any open menu panel by checking `document.querySelector('.notif-menu-btn[aria-expanded="true"]')` or similar
- Alternatively, use a module-level `openMenuPanel` variable; the single listener checks if the click target is outside `openMenuPanel` and closes it
**Acceptance criteria:**
- Only one document click listener exists regardless of notification count
- Menu panels still close when clicking outside
#### 1.4 Cap `posts[]` and `postIds` with rolling eviction
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html)
**Current behavior:** `posts` array and `postIds` Set grow without bound.
**Changes:**
- Add `const MAX_STORED_POSTS = 500` constant
- In `upsertFeedPost` / `upsertPost`, after pushing a new event, if `posts.length > MAX_STORED_POSTS`, sort by `created_at` descending, slice to `MAX_STORED_POSTS`, and rebuild `postIds` from the remaining array
- Also remove evicted post IDs from `renderedPostIds`
- In `post.html` event mode, skip this (single event display)
**Acceptance criteria:**
- `posts.length` never exceeds `MAX_STORED_POSTS`
- Evicted posts are removed from `postIds` and `renderedPostIds`
- "See More" still works within the capped array
#### 1.5 Cap `notificationsById` and `unreadNotificationsById` with periodic pruning
**Files:** [`www/notifications.html`](www/notifications.html)
**Current behavior:** Both Maps grow without bound; [`pruneNotificationsByCurrentSettings`](www/notifications.html:570) only runs on filter toggles.
**Changes:**
- Add `const MAX_STORED_NOTIFICATIONS = 500`
- Add a `pruneNotificationMaps()` function that:
- Sorts `notificationsById` entries by `created_at` descending
- Keeps only the top `MAX_STORED_NOTIFICATIONS`
- Deletes evicted entries from both `notificationsById` and `unreadNotificationsById`
- Also prunes `postImageCache` to an LRU of 200 entries
- Call `pruneNotificationMaps()` at the end of `addNotificationFromEvent` if `notificationsById.size > MAX_STORED_NOTIFICATIONS + 100`
- Also call it after `hydrateNotificationsFromCache` and `hydrateNotificationsFromRelays`
**Acceptance criteria:**
- `notificationsById.size` never exceeds `MAX_STORED_NOTIFICATIONS + 100`
- `postImageCache.size` never exceeds 200
- Filter counts remain accurate after pruning
---
### Phase 2: P1 — Subscription Hygiene & Polling Reduction
**Goal:** Stop the cross-tab event storm and reduce worker message queue saturation.
#### 2.1 Filter `ndkEvent` by `subId` on the page side
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html), [`www/notifications.html`](www/notifications.html), [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs)
**Current behavior:** The worker includes `subId` in event broadcasts ([`ndk-worker.js:1094`](www/ndk-worker.js:1094)), but pages' `ndkEvent` handlers process all events regardless of source subscription.
**Changes:**
- In `init-ndk.mjs`, the `ndkEvent` CustomEvent already includes the full `message.data` — verify `subId` is in `event.detail`
- Each page tracks its own active subscription IDs in a `Set`:
- `feed.html`: track the kind-3 sub and the live feed sub
- `post.html`: track the kind-1 author/event sub
- `notifications.html`: track the notifications sub
- In the `ndkEvent` handler, check `if (!mySubscriptionIds.has(event.detail.subId)) return;` before processing
**Acceptance criteria:**
- Pages only process events from their own subscriptions
- Events from other tabs' subscriptions are ignored (no CPU work, no DOM updates)
#### 2.2 Close old live subscriptions on follow-list change
**Files:** [`www/feed.html`](www/feed.html)
**Current behavior:** [`ensureLiveFeedSubscription`](www/feed.html:548) creates a new subscription when follows change but never closes the old one.
**Changes:**
- Add a module-level `let liveFeedSub = null;` variable
- In `ensureLiveFeedSubscription`, before creating the new subscription:
```js
if (liveFeedSub?.close) {
liveFeedSub.close();
}
```
- Assign the return of `subscribe()` to `liveFeedSub`
- Also track the kind-3 subscription similarly and close it on logout
**Acceptance criteria:**
- Only one live feed subscription exists at a time in the worker
- `activeSubscriptions` Map in the worker doesn't accumulate stale feed subscriptions
#### 2.3 Increase footer polling interval to 5 seconds (or event-driven)
**Files:** [`www/index.html`](www/index.html), [`www/feed.html`](www/feed.html), [`www/notifications.html`](www/notifications.html), [`www/relays.html`](www/relays.html)
**Current behavior:** `setInterval(UpdateFooter, 1000)` on every page.
**Changes:**
- Change all 1-second footer intervals to 5 seconds (matching `post.html` which already uses 5s)
- `index.html` line 850: `setInterval(UpdateFooter, 5000)`
- `feed.html` line 692: `setInterval(updateFooter, 5000)`
- `notifications.html` line 1390: `setInterval(UpdateFooter, 5000)`
- `relays.html` line 1794: `setInterval(UpdateFooter, 5000)`
- **Future enhancement (not in this phase):** Make footer fully event-driven by reacting to `ndkRelays` and `ndkRelayActivity` events instead of polling
**Acceptance criteria:**
- No page polls the worker for footer data more than once per 5 seconds
- Footer still shows current relay status (5s staleness is acceptable)
#### 2.4 Diff-update relay table instead of full rebuild
**Files:** [`www/relays.html`](www/relays.html)
**Current behavior:** [`createRelayTable`](www/relays.html:795) does `divRelays.innerHTML = html` every 5 seconds, destroying all rows, inputs, and listeners.
**Changes:**
- Split `createRelayTable` into:
- `buildRelayTable(relays)` — initial build (full innerHTML)
- `updateRelayTableRows(relays)` — in-place update of existing rows:
- Update status icon, read/write counts, connection time text
- Update checkbox SVGs if relay type changed
- Add/remove rows if relay count changed
- Preserve the add-relay input value and focus
- `refreshRelayData` calls `updateRelayTableRows` if the table already exists and the relay count matches; otherwise calls `buildRelayTable`
**Acceptance criteria:**
- Relay table updates without losing input focus or re-attaching all listeners
- Add-relay input value is preserved across refreshes (already partially implemented)
#### 2.5 Throttle `relayActivity` broadcasts in the worker
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
**Current behavior:** [`trackRelayRead`](www/ndk-worker.js:828) and [`trackRelayWrite`](www/ndk-worker.js:850) broadcast `relayActivity` on every single event read/write.
**Changes:**
- Add a `relayActivityBroadcastTimer` and a `pendingRelayActivity` Set of relay URLs that have had activity since the last broadcast
- In `trackRelayRead` / `trackRelayWrite`, add the relay URL to `pendingRelayActivity` and schedule a 500ms batched broadcast if not already scheduled
- The batched broadcast sends one `relayActivity` message per pending relay with current stats, then clears the set
- This reduces broadcast frequency from per-event to per-500ms-batch
**Acceptance criteria:**
- `relayActivity` broadcasts are batched to at most once per 500ms
- Stats counts remain accurate (they're cumulative)
- Footer/sidenav relay indicators still update within 1s of activity
---
### Phase 3: P2 — Worker Cleanup & Cache Eviction
**Goal:** Prevent unbounded growth in worker-side state and clean up dead connections.
#### 3.1 Prune `connectedPorts` on tab close
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
**Current behavior:** [`connectedPorts`](www/ndk-worker.js:260) accumulates dead ports; [`broadcast()`](www/ndk-worker.js:2479) iterates all of them.
**Changes:**
- In the `onconnect` handler ([`ndk-worker.js:7600`](www/ndk-worker.js:7600)), add:
```js
port.onclose = () => {
connectedPorts = connectedPorts.filter(p => p !== port);
if (activeSigningPort === port) activeSigningPort = null;
};
```
**Acceptance criteria:**
- `connectedPorts` only contains live ports
- `broadcast()` doesn't iterate dead ports
#### 3.2 Evict `inlineComposerInstances` on re-render
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html)
**Current behavior:** `inlineComposerInstances` Map retains entries with detached `hostEl` after `renderFeed()` clears the container.
**Changes:**
- At the start of `renderFeed()` (the full rebuild path), after `divFeed.innerHTML = ''`:
```js
inlineComposerInstances.clear();
```
- Since the DOM is rebuilt, all composer host elements are gone; the Map entries are pure garbage
**Acceptance criteria:**
- `inlineComposerInstances` is empty after a full re-render
- Composers can still be re-created on demand via `getOrCreateInlineComposer`
#### 3.3 Prune temporary relay Maps after broadcast publish
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
**Current behavior:** `relayActivityStats`, `relayConnectAttemptStartedAt`, `relayLastDisconnectMeta` accumulate entries for every temporary broadcast relay.
**Changes:**
- After the broadcast publish `phase: 'done'` block ([`ndk-worker.js:6410`](www/ndk-worker.js:6410)), add a cleanup step:
```js
const userRelayUrls = new Set(Array.from(relayTypes.keys()).map(normalizeRelayUrl));
for (const url of relayActivityStats.keys()) {
if (!userRelayUrls.has(url)) relayActivityStats.delete(url);
}
relayConnectAttemptStartedAt.clear(); // only used during active connect attempts
// Keep relayLastDisconnectMeta for reconnect detection
```
- Only prune `relayActivityStats` — the disconnect meta is needed for reconnect subscription rebuilding
**Acceptance criteria:**
- `relayActivityStats` only contains entries for the user's own relays after a broadcast completes
- Reconnect detection still works (disconnect meta preserved)
#### 3.4 Periodic reconciliation of `lightwalletTokenEvents`
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
**Current behavior:** `lightwalletTokenEvents` and `lightwalletDeletedIds` grow monotonically; deleted entries are only removed during `publishDirectProofs`.
**Changes:**
- Add a 5-minute interval timer `lightwalletReconcileTimer` that:
- Iterates `lightwalletTokenEvents` and removes any entry whose id is in `lightwalletDeletedIds`
- Optionally trims `lightwalletDeletedIds` to the most recent 500 entries (keep recent ones to prevent re-processing)
- Clear this timer in `handleDisconnect`
**Acceptance criteria:**
- `lightwalletTokenEvents` doesn't retain deleted token events
- `lightwalletDeletedIds` is bounded to ~500 entries
#### 3.5 Clear health-check and prune intervals on disconnect
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
**Current behavior:** `startRelayHealthCheck` ([`ndk-worker.js:1262`](www/ndk-worker.js:1262)) and `scheduleRelayEventPruning` ([`ndk-worker.js:789`](www/ndk-worker.js:789)) create intervals that are never cleared.
**Changes:**
- Store the health-check interval ID in a module-level variable `relayHealthCheckTimer`
- Store the prune interval ID in `relayEventsPruneTimer` (already exists)
- In `handleDisconnect()` ([`ndk-worker.js:7350`](www/ndk-worker.js:7350)), add:
```js
if (relayHealthCheckTimer) { clearInterval(relayHealthCheckTimer); relayHealthCheckTimer = null; }
if (relayEventsPruneTimer) { clearInterval(relayEventsPruneTimer); relayEventsPruneTimer = null; }
```
**Acceptance criteria:**
- No duplicate intervals after re-init
- Intervals are cleared on disconnect
---
### Phase 4: P3 — Minor Leaks & Polish
**Goal:** Clean up remaining minor leaks and harden against edge cases.
#### 4.1 Clear `refreshTimes` interval on notifications logout
**Files:** [`www/notifications.html`](www/notifications.html)
**Current behavior:** [`setInterval(refreshTimes, 30000)`](www/notifications.html:1392) is never assigned to a variable and can't be cleared.
**Changes:**
- Assign to `let refreshTimesIntervalId = setInterval(refreshTimes, 30000)`
- Clear it in `Logout()`
#### 4.2 Remove duplicate `window.addEventListener('message', ...)` in relays.html
**Files:** [`www/relays.html`](www/relays.html)
**Current behavior:** [`relays.html:1777`](www/relays.html:1777) has a fallback `message` listener that duplicates the `ndkRelayActivity` handler.
**Changes:**
- Remove the `window.addEventListener('message', ...)` block at lines 17771783
- The `ndkRelayActivity` handler at line 1762 already covers this
#### 4.3 Add `beforeunload` cleanup for subscriptions on all pages
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html), [`www/notifications.html`](www/notifications.html)
**Current behavior:** Pages don't close their subscriptions on `beforeunload`, leaving stale entries in the worker's `activeSubscriptions` Map until the worker detects the port close.
**Changes:**
- Add `window.addEventListener('beforeunload', () => { disconnect(); })` (or close individual subscriptions) on each page
- The `disconnect()` function in `init-ndk.mjs` already sends a `disconnect` message and closes the port
**Acceptance criteria:**
- Worker's `activeSubscriptions` is cleaned up when a tab closes
---
## Testing Strategy
### Manual Testing
1. Open `index.html`, `feed.html`, `post.html`, `notifications.html`, `relays.html` in separate tabs
2. Leave them running for 30+ minutes with active subscriptions
3. Check browser DevTools Memory tab:
- Take heap snapshots at 5min, 15min, 30min
- Verify heap growth is sublinear (not growing with event count)
4. Check Performance tab:
- Record a 30s profile while events are flowing
- Verify no full `renderFeed` / `renderNotifications` on every event
5. Check that relay footer updates within 5s of connection changes
6. Check that notification menu panels still open/close correctly
### Automated Checks
- After each phase, run the existing test suite (`tests/broadcast-relay-test.sh` etc.)
- Add a memory regression test if feasible (puppeteer heap snapshot comparison)
## Risk Assessment
| Phase | Risk | Mitigation |
|-------|------|------------|
| Phase 1 (append-only) | Sort order bugs if new event is older than visible posts | Only prepend if newer than first visible; fall back to full render otherwise |
| Phase 1 (listener fix) | Menu panel might not close in all cases | Test thoroughly; use single delegated listener |
| Phase 2 (subId filter) | Might miss events if subId tracking is wrong | Log filtered vs processed events during testing |
| Phase 2 (polling change) | Footer might feel less responsive | 5s is still fast; relay status rarely changes in seconds |
| Phase 3 (worker cleanup) | Might prune stats too aggressively | Only prune temp relay stats, not user's own relays |
## Files Modified
| File | Phases |
|------|--------|
| `www/feed.html` | 1, 2, 3, 4 |
| `www/post.html` | 1, 2, 3, 4 |
| `www/notifications.html` | 1, 2, 3, 4 |
| `www/relays.html` | 2, 4 |
| `www/index.html` | 2 |
| `www/ndk-worker.js` | 2, 3 |
| `www/js/init-ndk.mjs` | 2 |
## Implementation Checklist
### Phase 1: P0 — Critical Rendering & Listener Leaks
- [x] **1.1** Append-only rendering for feed and post pages
- [x] Split `renderFeed()` into full rebuild + `prependPostCard()`
- [x] New posts prepend without rebuilding existing cards
- [x] Cap visible DOM to `displayCount` (trims last child on overflow)
- [x] "See More" still does full re-render
- [x] Verified: `data-post-id` attribute set by `renderPostItem` for trim logic
- [x] Reviewed: no errors found, fallback paths correct
- [ ] **1.2** Append-only rendering for notifications page
- [ ] Split `renderNotifications()` into full rebuild + `prependNotificationRow()`
- [ ] New notifications prepend without rebuilding existing rows
- [ ] Filter toggles and "View more" still do full re-renders
- [ ] **1.3** Fix notification `document.click` listener leak
- [ ] Remove per-row `document.addEventListener('click', ...)`
- [ ] Register single delegated document-level click listener in `main()`
- [ ] Menu panels still close when clicking outside
- [ ] **1.4** Cap `posts[]` and `postIds` with rolling eviction
- [ ] Add `MAX_STORED_POSTS = 500` constant
- [ ] Evict oldest posts when array exceeds cap
- [ ] Rebuild `postIds` and `renderedPostIds` after eviction
- [ ] **1.5** Cap `notificationsById` / `unreadNotificationsById` with periodic pruning
- [ ] Add `MAX_STORED_NOTIFICATIONS = 500`
- [ ] Add `pruneNotificationMaps()` function
- [ ] Call prune on overflow, after cache/relay hydration
- [ ] LRU-cap `postImageCache` to 200 entries
### Phase 2: P1 — Subscription Hygiene & Polling Reduction
- [ ] **2.1** Filter `ndkEvent` by `subId` on the page side
- [ ] Verify `subId` is in `event.detail` from init-ndk.mjs
- [ ] Track active subscription IDs per page
- [ ] Ignore events from foreign subscriptions
- [ ] **2.2** Close old live subscriptions on follow-list change
- [ ] Store live feed subscription handle
- [ ] Close previous subscription before creating new one
- [ ] **2.3** Increase footer polling to 5 seconds
- [ ] index.html: 1s → 5s
- [ ] feed.html: 1s → 5s
- [ ] notifications.html: 1s → 5s
- [ ] relays.html: 1s → 5s
- [ ] **2.4** Diff-update relay table instead of full rebuild
- [ ] Split `createRelayTable` into build + update
- [ ] In-place update of status icons, counts, checkboxes
- [ ] Preserve add-relay input focus across refreshes
- [ ] **2.5** Throttle `relayActivity` broadcasts in the worker
- [ ] Add batched broadcast with 500ms timer
- [ ] Coalesce per-event broadcasts into per-batch
- [ ] Stats counts remain accurate
### Phase 3: P2 — Worker Cleanup & Cache Eviction
- [ ] **3.1** Prune `connectedPorts` on tab close
- [ ] Add `port.onclose` handler in `onconnect`
- [ ] Clear `activeSigningPort` if it was the closed port
- [ ] **3.2** Evict `inlineComposerInstances` on re-render
- [ ] Clear Map after `innerHTML = ''` in full rebuild path
- [ ] **3.3** Prune temporary relay Maps after broadcast publish
- [ ] Remove non-user relay entries from `relayActivityStats`
- [ ] Clear `relayConnectAttemptStartedAt`
- [ ] Preserve `relayLastDisconnectMeta` for reconnect detection
- [ ] **3.4** Periodic reconciliation of `lightwalletTokenEvents`
- [ ] Add 5-minute interval timer
- [ ] Remove deleted entries from `lightwalletTokenEvents`
- [ ] Trim `lightwalletDeletedIds` to 500
- [ ] Clear timer in `handleDisconnect`
- [ ] **3.5** Clear health-check and prune intervals on disconnect
- [ ] Store health-check interval ID
- [ ] Clear both intervals in `handleDisconnect`
### Phase 4: P3 — Minor Leaks & Polish
- [ ] **4.1** Clear `refreshTimes` interval on notifications logout
- [ ] **4.2** Remove duplicate `message` listener in relays.html
- [ ] **4.3** Add `beforeunload` subscription cleanup on all pages

File diff suppressed because it is too large Load Diff

95
tests/broadcast-relay-test.sh Executable file
View File

@@ -0,0 +1,95 @@
#!/bin/bash
# tests/broadcast-relay-test.sh
#
# Tests publishing a kind 1 note to each relay in tests/relays.json
# using nak, one relay at a time. Reports success/failure for each.
#
# Usage:
# ./tests/broadcast-relay-test.sh
#
# Requirements:
# - nak (https://github.com/fiatjaf/nak) installed and in PATH
# - tests/test-key.txt containing a hex private key
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RELAYS_FILE="$SCRIPT_DIR/relays.json"
RESULTS_FILE="$SCRIPT_DIR/broadcast-relay-results.txt"
KEY_FILE="$SCRIPT_DIR/test-key.txt"
# Check for nak
if ! command -v nak &>/dev/null; then
echo "ERROR: nak is not installed."
exit 1
fi
# Check for key file
if [ ! -f "$KEY_FILE" ]; then
echo "ERROR: $KEY_FILE not found. Generate one with: nak key generate > $KEY_FILE"
exit 1
fi
SEC=$(cat "$KEY_FILE" | tr -d '[:space:]')
# Extract relay URLs from the JSON file
echo "Extracting relay URLs from $RELAYS_FILE..."
mapfile -t RELAYS < <(python3 -c "
import json
with open('$RELAYS_FILE') as f:
data = json.load(f)
for tag in data:
if isinstance(tag, list) and len(tag) >= 2 and tag[0] == 'r':
print(tag[1])
")
TOTAL=${#RELAYS[@]}
echo "Found $TOTAL relay URLs"
echo ""
CONTENT="Broadcast relay test $(date -u +%Y-%m-%dT%H:%M:%SZ) — testing relay reachability via nak"
echo "Test content: $CONTENT"
echo ""
# Initialize results file
{
echo "# Broadcast Relay Test Results"
echo "# Date: $(date -u)"
echo "# Total relays: $TOTAL"
echo "# Test content: $CONTENT"
echo ""
} > "$RESULTS_FILE"
SUCCESS=0
FAILED=0
COUNT=0
for RELAY in "${RELAYS[@]}"; do
COUNT=$((COUNT + 1))
[ -z "$RELAY" ] && continue
printf "[%d/%d] %s ... " "$COUNT" "$TOTAL" "$RELAY"
RESULT=$(timeout 15 nak event --sec "$SEC" -c "$CONTENT" "$RELAY" 2>&1) || RESULT="FAILED: exit code $?"
if echo "$RESULT" | grep -qi "success"; then
echo "OK"
echo "OK: $RELAY" >> "$RESULTS_FILE"
SUCCESS=$((SUCCESS + 1))
elif echo "$RESULT" | grep -qi "error\|failed\|refused\|timeout\|reject\|blocked"; then
echo "FAIL"
echo "FAIL: $RELAY$RESULT" >> "$RESULTS_FILE"
FAILED=$((FAILED + 1))
else
echo "UNKNOWN"
echo "UNKNOWN: $RELAY$RESULT" >> "$RESULTS_FILE"
fi
done
echo ""
echo "=== Summary ==="
echo "Total: $TOTAL"
echo "Success: $SUCCESS"
echo "Failed: $FAILED"
echo ""
echo "Full results: $RESULTS_FILE"

View File

@@ -0,0 +1,660 @@
Extracting relay URLs from /home/user/lt/client/tests/relays.json...
Found 648 relay URLs
Test content: Broadcast relay test 2026-06-30T18:29:37Z — testing relay reachability via nak
[1/648] wss://nos.lol/ ... OK
[2/648] wss://relay.primal.net/ ... OK
[3/648] wss://nostr.wine/ ... FAIL
[4/648] wss://relay.snort.social/ ... OK
[5/648] wss://eden.nostr.land/ ... FAIL
[6/648] wss://relay.nostr.band/ ... FAIL
[7/648] wss://nostr.bitcoiner.social/ ... OK
[8/648] wss://purplepag.es/ ... FAIL
[9/648] wss://nostr.land/ ... FAIL
[10/648] wss://nostr-pub.wellorder.net/ ... OK
[11/648] wss://nostr.oxtr.dev/ ... OK
[12/648] wss://offchain.pub/ ... OK
[13/648] wss://relay.current.fyi/ ... FAIL
[14/648] wss://relay.nostr.bg/ ... FAIL
[15/648] wss://pyramid.fiatjaf.com/ ... FAIL
[16/648] wss://nostr.orangepill.dev/ ... FAIL
[17/648] wss://relay.ditto.pub/ ... OK
[18/648] wss://nostr.fmt.wiz.biz/ ... FAIL
[19/648] wss://brb.io/ ... FAIL
[20/648] wss://nostrelites.org/ ... FAIL
[21/648] wss://puravida.nostr.land/ ... FAIL
[22/648] wss://wot.utxo.one/ ... FAIL
[23/648] wss://nostr.mutinywallet.com/ ... FAIL
[24/648] wss://relay.nostr.info/ ... FAIL
[25/648] wss://nostr.milou.lol/ ... FAIL
[26/648] wss://relay.orangepill.dev/ ... FAIL
[27/648] wss://relayable.org/ ... FAIL
[28/648] wss://filter.nostr.wine/ ... FAIL
[29/648] wss://relay.nostrplebs.com/ ... FAIL
[30/648] wss://atlas.nostr.land/ ... FAIL
[31/648] wss://theforest.nostr1.com/ ... FAIL
[32/648] wss://nostr.zebedee.cloud/ ... FAIL
[33/648] wss://relay.0xchat.com/ ... OK
[34/648] wss://relay.nos.social/ ... OK
[35/648] wss://welcome.nostr.wine/ ... FAIL
[36/648] wss://bitcoiner.social/ ... FAIL
[37/648] wss://relay.noswhere.com/ ... OK
[38/648] wss://no.str.cr/ ... OK
[39/648] wss://nostr-pub.semisol.dev/ ... FAIL
[40/648] wss://nostr.einundzwanzig.space/ ... FAIL
[41/648] wss://relay.nostr.com.au/ ... FAIL
[42/648] wss://relay.fountain.fm/ ... OK
[43/648] wss://relay.nostrati.com/ ... FAIL
[44/648] wss://hist.nostr.land/ ... FAIL
[45/648] wss://nostr-relay.wlvs.space/ ... FAIL
[46/648] wss://nostr.onsats.org/ ... FAIL
[47/648] wss://relay.nostrcheck.me/ ... OK
[48/648] wss://nostr21.com/ ... FAIL
[49/648] wss://purplerelay.com/ ... OK
[50/648] wss://relay.bitcoinpark.com/ ... FAIL
[51/648] wss://relay.plebstr.com/ ... FAIL
[52/648] wss://wot.nostr.party/ ... FAIL
[53/648] wss://yabu.me/ ... FAIL
[54/648] wss://lightningrelay.com/ ... FAIL
[55/648] wss://relay.noderunners.network/ ... FAIL
[56/648] wss://spatia-arcana.com/ ... FAIL
[57/648] wss://140.f7z.io/ ... FAIL
[58/648] wss://nostr-01.yakihonne.com/ ... OK
[59/648] wss://nostr.inosta.cc/ ... FAIL
[60/648] wss://relay.f7z.io/ ... FAIL
[61/648] wss://relay.getalby.com/v1 ... FAIL
[62/648] wss://relay.momostr.pink/ ... FAIL
[63/648] wss://wot.sovbit.host/ ... FAIL
[64/648] wss://aggr.nostr.land/ ... FAIL
[65/648] wss://bitcoinmaximalists.online/ ... FAIL
[66/648] wss://nostr.plebchain.org/ ... FAIL
[67/648] wss://algo.utxo.one/ ... FAIL
[68/648] wss://at.nostrworks.com/ ... FAIL
[69/648] wss://bevo.nostr1.com/ ... FAIL
[70/648] wss://nostr.bitcoinplebs.de/ ... FAIL
[71/648] wss://nostr.relayer.se/ ... FAIL
[72/648] wss://nostr.rocks/ ... FAIL
[73/648] wss://nostr.walletofsatoshi.com/ ... FAIL
[74/648] wss://relay.stoner.com/ ... FAIL
[75/648] wss://relay.utxo.one/ ... FAIL
[76/648] wss://rsslay.nostr.net/ ... FAIL
[77/648] ws://umbrel.local:4848/ ... FAIL
[78/648] wss://auth.nostr1.com/ ... FAIL
[79/648] wss://bitsat.molonlabe.holdings/ ... FAIL
[80/648] wss://creatr.nostr.wine/ ... FAIL
[81/648] wss://nostr.sethforprivacy.com/ ... FAIL
[82/648] wss://nostr.slothy.win/ ... FAIL
[83/648] wss://nostr.v0l.io/ ... FAIL
[84/648] wss://nostr.zbd.gg/ ... FAIL
[85/648] wss://relay.azzamo.net/ ... FAIL
[86/648] wss://relay.nostr.net/ ... OK
[87/648] wss://relay.nostriches.org/ ... FAIL
[88/648] wss://relay.nostrview.com/ ... FAIL
[89/648] wss://wot.girino.org/ ... FAIL
[90/648] ws://127.0.0.1:4869/ ... FAIL
[91/648] wss://e.nos.lol/ ... FAIL
[92/648] wss://nostr-02.yakihonne.com/ ... OK
[93/648] wss://nostr.cercatrova.me/ ... FAIL
[94/648] wss://nostr.chaima.info/ ... OK
[95/648] wss://nostr.cypherpunk.today/ ... FAIL
[96/648] wss://nostr.portemonero.com/ ... FAIL
[97/648] wss://nostr.sandwich.farm/ ... FAIL
[98/648] wss://nostr.thesamecat.io/ ... FAIL
[99/648] wss://nostrue.com/ ... FAIL
[100/648] wss://relay-jp.nostr.wirednet.jp/ ... FAIL
[101/648] wss://relay.lnau.net/ ... FAIL
[102/648] wss://relay.siamstr.com/ ... FAIL
[103/648] wss://relay.wavlake.com/ ... OK
[104/648] wss://satsage.xyz/ ... FAIL
[105/648] wss://wheat.happytavern.co/ ... OK
[106/648] wss://wot.sudocarlos.com/ ... FAIL
[107/648] wss://basspistol.org/ ... FAIL
[108/648] wss://cellar.nostr.wine/ ... FAIL
[109/648] wss://feeds.nostr.band/popular ... FAIL
[110/648] wss://freelay.sovbit.host/ ... OK
[111/648] wss://inbox.azzamo.net/ ... FAIL
[112/648] wss://inbox.nostr.wine/ ... FAIL
[113/648] wss://lightning.red/ ... FAIL
[114/648] wss://nfdb.noswhere.com/ ... FAIL
[115/648] wss://nostr-03.dorafactory.org/ ... FAIL
[116/648] wss://nostr-relay.derekross.me/ ... FAIL
[117/648] wss://nostr-relay.digitalmob.ro/ ... FAIL
[118/648] wss://nostr.pleb.network/ ... FAIL
[119/648] wss://nostr.semisol.dev/ ... FAIL
[120/648] wss://nostr.thank.eu/ ... FAIL
[121/648] wss://nwc.primal.net/ayvjleilmx0al7j2pqt24qed1z7a8s ... FAIL
[122/648] wss://orangepiller.org/ ... FAIL
[123/648] wss://relay.44billion.net/ ... OK
[124/648] wss://relay.divine.video/ ... OK
[125/648] wss://relay.mutinywallet.com/ ... FAIL
[126/648] wss://relay.nostr.ch/ ... FAIL
[127/648] wss://relay.nostr.nu/ ... FAIL
[128/648] wss://relay.otherstuff.fyi/ ... FAIL
[129/648] wss://relay.routstr.com/ ... OK
[130/648] wss://relay.shitforce.one/ ... FAIL
[131/648] wss://relay.utxo.one/outbox ... FAIL
[132/648] wss://rilo.nostria.app/ ... OK
[133/648] wss://search.nos.today/ ... FAIL
[134/648] wss://wot.nostr.net/ ... FAIL
[135/648] wss://wot.shaving.kiwi/ ... FAIL
[136/648] wss://wot.siamstr.com/ ... FAIL
[137/648] wss://xmr.usenostr.org/ ... FAIL
[138/648] wss://zap.watch/ ... FAIL
[139/648] ws://127.0.0.1:8888/ ... FAIL
[140/648] wss://adult.18plus.social/ ... FAIL
[141/648] wss://anon.computer/ ... FAIL
[142/648] wss://bostr.azzamo.net/ ... FAIL
[143/648] wss://chorus.mikedilger.com:444/ ... FAIL
[144/648] wss://deschooling.us/ ... FAIL
[145/648] wss://ditto.pub/relay ... OK
[146/648] wss://feeds.nostr.band/typescript ... FAIL
[147/648] wss://filter.nostr.wine/npub1s5yq6wadwrxde4lhfs56gn64hwzuhnfa6r9mj476r5s4hkunzgzqrs6q7z ... FAIL
[148/648] wss://global-relay.cesc.trade/ ... FAIL
[149/648] wss://greensoul.space/ ... FAIL
[150/648] wss://haven.calva.dev/ ... FAIL
[151/648] wss://indexer.coracle.social/ ... FAIL
[152/648] wss://lunchbox.sandwich.farm/ ... FAIL
[153/648] wss://me.purplerelay.com/ ... FAIL
[154/648] wss://misskey.04.si/ ... FAIL
[155/648] wss://news.utxo.one/ ... FAIL
[156/648] wss://nostr-02.dorafactory.org/ ... FAIL
[157/648] wss://nostr-1.nbo.angani.co/ ... FAIL
[158/648] wss://nostr-2.zebedee.cloud/ ... FAIL
[159/648] wss://nostr-dev.wellorder.net/ ... OK
[160/648] wss://nostr-relay.bitcoin.ninja/ ... FAIL
[161/648] wss://nostr-relay.nokotaro.com/ ... FAIL
[162/648] wss://nostr.0x7e.xyz/ ... OK
[163/648] wss://nostr.88mph.life/ ... OK
[164/648] wss://nostr.azte.co/ ... FAIL
[165/648] wss://nostr.azzamo.net/ ... OK
[166/648] wss://nostr.bitpunk.fm/ ... FAIL
[167/648] wss://nostr.data.haus/ ... OK
[168/648] wss://nostr.lopp.social/ ... FAIL
[169/648] wss://nostr.lorentz.is/ ... FAIL
[170/648] wss://nostr.lu.ke/ ... FAIL
[171/648] wss://nostr.roundrockbitcoiners.com/ ... FAIL
[172/648] wss://nostr.sebastix.dev/ ... FAIL
[173/648] wss://nostr.self-determined.de/ ... FAIL
[174/648] wss://nostr.sovbit.host/ ... FAIL
[175/648] wss://nostr.stakey.net/ ... FAIL
[176/648] wss://nostr.tavux.tech/ ... FAIL
[177/648] wss://nostr.vulpem.com/ ... OK
[178/648] wss://nostr.xmr.rocks/ ... FAIL
[179/648] wss://nostr01.sharkshake.net/ ... FAIL
[180/648] wss://nostrcheck.me/relay ... OK
[181/648] wss://nostrsatva.net/ ... FAIL
[182/648] wss://nrelay.c-stellar.net/ ... FAIL
[183/648] wss://paid.no.str.cr/ ... FAIL
[184/648] wss://public.nostr.swissrouting.com/ ... FAIL
[185/648] wss://r.hostr.cc/ ... FAIL
[186/648] wss://relay.bitblockboom.com/ ... FAIL
[187/648] wss://relay.bitcoindistrict.org/ ... FAIL
[188/648] wss://relay.bullishbounty.com/ ... OK
[189/648] wss://relay.cashumints.space/ ... FAIL
[190/648] wss://relay.coinos.io/ ... OK
[191/648] wss://relay.damus.com/ ... FAIL
[192/648] wss://relay.dergigi.com/ ... FAIL
[193/648] wss://relay.dreamith.to/ ... OK
[194/648] wss://relay.exit.pub/ ... FAIL
[195/648] wss://relay.gasteazi.net/ ... OK
[196/648] wss://relay.geekiam.services/ ... FAIL
[197/648] wss://relay.getsafebox.app/ ... OK
[198/648] wss://relay.kamp.site/ ... FAIL
[199/648] wss://relay.martien.io/ ... FAIL
[200/648] wss://relay.nosflare.com/ ... FAIL
[201/648] wss://relay.nostr.band/all ... FAIL
[202/648] wss://relay.nostr.hu/ ... FAIL
[203/648] wss://relay.nostr.pro/ ... FAIL
[204/648] wss://relay.nostr.wirednet.jp/ ... OK
[205/648] wss://relay.nostrgraph.net/ ... FAIL
[206/648] wss://relay.nostrica.com/ ... FAIL
[207/648] wss://relay.nostrich.de/ ... FAIL
[208/648] wss://relay.nostrss.re/ ... FAIL
[209/648] wss://relay.notoshi.win/ ... OK
[210/648] wss://relay.nsecbunker.com/ ... FAIL
[211/648] wss://relay.s-w.art/ ... FAIL
[212/648] wss://relay.sigit.io/ ... OK
[213/648] wss://relay.sovbit.host/ ... FAIL
[214/648] wss://relay.sovereignengineering.io/ ... FAIL
[215/648] wss://relay.vertexlab.io/ ... FAIL
[216/648] wss://relay.wellorder.net/ ... OK
[217/648] wss://relay.westernbtc.com/ ... FAIL
[218/648] wss://relayer.fiatjaf.com/ ... FAIL
[219/648] wss://ribo.eu.nostria.app/ ... OK
[220/648] wss://ribo.nostria.app/ ... OK
[221/648] wss://temp.iris.to/ ... OK
[222/648] wss://thebarn.nostr1.com/ ... FAIL
[223/648] wss://tigs.nostr1.com/ ... FAIL
[224/648] wss://vault.iris.to/ ... OK
[225/648] wss://vitor.nostr1.com/ ... FAIL
[226/648] wss://xmr.ithurtswhenip.ee/ ... FAIL
[227/648] ⬤ wss://nostr-pub.wellorder.net ... FAIL
[228/648] coracle ... FAIL
[229/648] https://sovbitm2enxfr5ot6qscwy5ermdffbqscy66wirkbsigvcshumyzbbqd.onion/ ... FAIL
[230/648] ws://192.168.1.63:4848/ ... FAIL
[231/648] ws://2tkf2psfhves3tp2izcchzc5gpiowk4gzkcqls4l46wj5v4b772tyead.onion/ ... FAIL
[232/648] ws://bitcoin.anneca.cz:4848/ ... FAIL
[233/648] ws://eden.nostr.land/ ... FAIL
[234/648] ws://ehsc3vsoqugf5dyzvop2o4ii7nos3yccirirlxmycx4hle5epbxppnad.onion/ ... FAIL
[235/648] ws://k6dpciogx4fabnipku6wlce4rjv3ffjhv6gcundcxvxn6poeq2hcn3id.onion/ ... FAIL
[236/648] ws://localhost:4869/ ... FAIL
[237/648] ws://qwqk4b6royrrgmk4pvoccok4wt5fcvpulwnse5656bcqt3bncokpt3qd.onion/ ... FAIL
[238/648] ws://relay.jb55.com/ ... FAIL
[239/648] ws://w3xtwz6dc7krqkkm22odcvpdpc5escq7lg266quupdyatxr5gjcm55ad.onion/ ... FAIL
[240/648] ws://willem.currycash.net:4848/ ... FAIL
[241/648] wss://1.noztr.com/ ... FAIL
[242/648] wss://2jsnlhfnelig5acq6iacydmzdbdmg7xwunm4xl6qwbvzacw4lwrjmlyd.onion/ ... FAIL
[243/648] wss://64.23.224.231:54711/ ... FAIL
[244/648] wss://adfasfasfadsdfasfasf3123412ewfas.xyz/ ... FAIL
[245/648] wss://adre.su/ ... FAIL
[246/648] wss://ae.purplerelay.com/ ... FAIL
[247/648] wss://ae3t4h2.oops.wtf/ ... FAIL
[248/648] wss://aegis.utxo.one/ ... FAIL
[249/648] wss://alphapanda.pro/ ... FAIL
[250/648] wss://art.nostrfreaks.com/ ... FAIL
[251/648] wss://asia.azzamo.net/ ... FAIL
[252/648] wss://atlas.nostr.land/invoices ... FAIL
[253/648] wss://au.relayable.org/ ... FAIL
[254/648] wss://bitcoinmajlis.nostr1.com/ ... FAIL
[255/648] wss://bitcoinostr.duckdns.org/ ... OK
[256/648] wss://bitcoinr6de5lkvx4tpwdmzrdfdpla5sya2afwpcabjup2xpi5dulbad.onion/ ... FAIL
[257/648] wss://bitstack.app/ ... FAIL
[258/648] wss://catstrr.swarmstr.com/ ... FAIL
[259/648] wss://christpill.nostr1.com/ ... FAIL
[260/648] wss://ciao.rinbal.de/ ... FAIL
[261/648] wss://cyberspace.nostr1.com/ ... OK
[262/648] wss://db4efobus2ilttw5gokcwck3okcr476hqkmyqduoyydlo2j7iigehkyd.local/ ... FAIL
[263/648] wss://directory.yabu.me/alpha-ember ... FAIL
[264/648] wss://echo.websocket.org/ ... FAIL
[265/648] wss://eclipse.pub/relay ... FAIL
[266/648] wss://ehsc3vsoqugf5dyzvop2o4ii7nos3yccirirlxmycx4hle5epbxppnad.local/ ... FAIL
[267/648] wss://elites.nostrati.org/ ... FAIL
[268/648] wss://espelho.girino.org/ ... FAIL
[269/648] wss://essayist.decentnewsroom.com/ ... FAIL
[270/648] wss://eu.nostr.pikachat.org/juliet-alpha-nexus ... FAIL
[271/648] wss://eu.rbr.bio/ ... FAIL
[272/648] wss://eyes.f7z.io/ ... FAIL
[273/648] wss://fabian.nostr1.com/ ... FAIL
[274/648] wss://fanfares.nostr1.com/ ... OK
[275/648] wss://feeds.nostr.band/omni__ventures ... FAIL
[276/648] wss://feeds.nostr.band/pics ... FAIL
[277/648] wss://fiatjaf.nostr1.com/ ... FAIL
[278/648] wss://filter.nostr.wine/?global=all ... FAIL
[279/648] wss://filter.nostr.wine/npub12gu8c6uee3p243gez6cgk76362admlqe72aq3kp2fppjsjwmm7eqj9fle6?broadcast=true ... FAIL
[280/648] wss://filter.nostr.wine/npub14yzxejghthz6ghae8gkgjru63vvvwpl6d454wud2hycqpqwnugdqcutuw5?broadcast=true ... FAIL
[281/648] wss://filter.nostr.wine/npub153xmex42x4chdf757hp3q6zxagykkek7pdgwuwd074964dkyha9s82ryu8?broadcast=true ... FAIL
[282/648] wss://filter.nostr.wine/npub16w4nxxv7kjxx77zsw262v65w27q5udwnzd6ue2xremhvc9clxzaq974vef?broadcast=true ... FAIL
[283/648] wss://filter.nostr.wine/npub17xu3rtcu0ftqw03mswa8a2ngz3nsgrs0h0wjv4z942qwvhp8fs3qzcadls?broadcast=true ... FAIL
[284/648] wss://filter.nostr.wine/npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424?broadcast=true ... FAIL
[285/648] wss://filter.nostr.wine/npub1a6zkqnuwcmjwynuw4u4xyngy9675x8dwgj87z9me4h8mdwmc2a0q8mvhjk?broadcast=true ... FAIL
[286/648] wss://filter.nostr.wine/npub1cuyfg04rf9gemn6kk2juzw8anmgxftt9mhk2ucu5a27c0f30zacqggzw8d?broadcast=true ... FAIL
[287/648] wss://filter.nostr.wine/npub1du6sgl90wse0cz44fg50a4kg9ea4sgctlxps90ccx58lw8ssgv9qhjyf3c?broadcast=true ... FAIL
[288/648] wss://filter.nostr.wine/npub1ngumlqmus6xkrmvvee4yc7swh9h4uk7vpq4ddt7a2jtvkc22y0asrse3pv?broadcast=true ... FAIL
[289/648] wss://filter.nostr.wine/npub1nh4z0p2ewjsgln4533q04wzr9sdguwjn58dz9gdd26zet4spqeysktrh05?broadcast=true ... FAIL
[290/648] wss://filter.nostr.wine/npub1p4kg8zxukpym3h20erfa3samj00rm2gt4q5wfuyu3tg0x3jg3gesvncxf8 ... FAIL
[291/648] wss://filter.nostr.wine/npub1qjgcmlpkeyl8mdkvp4s0xls4ytcux6my606tgfx9xttut907h0zs76lgjw?broadcast=true ... FAIL
[292/648] wss://filter.nostr.wine/npub1rr654u0pp3dm0g65dzc0v2eft5frg7gre8u4wwxsvhyyhmc5qths50eeul?broadcast=true ... FAIL
[293/648] wss://filter.nostr.wine/npub1rtlqca8r6auyaw5n5h3l5422dm4sry5dzfee4696fqe8s6qgudks7djtfs?broadcast=true ... FAIL
[294/648] wss://filter.nostr.wine/npub1sjjz60h6fqqcuxrsyl3thhgpx2z6ylv047tslqar26ga0chp5vgq7404nu?broadcast=true ... FAIL
[295/648] wss://filter.nostr.wine/npub1t0nyg64g5vwprva52wlcmt7fkdr07v5dr7s35raq9g0xgc0k4xcsedjgqv?broadcast=true ... FAIL
[296/648] wss://filter.nostr.wine/npub1t5wc8h37uhkau9tsw82sjxndq04d3n8p634utpdfvs4tm5xmt2sqgk6dke?broadcast=true ... FAIL
[297/648] wss://filter.nostr.wine/npub1xv6axulxcx6mce5mfvfzpsy89r4gee3zuknulm45cqqpmyw7680q5pxea6?broadcast=true ... FAIL
[298/648] wss://filter.nostr.wine/npub1yaul8k059377u9lsu67de7y637w4jtgeuwcmh5n7788l6xnlnrgs3tvjmf?broadcast=true ... FAIL
[299/648] wss://filter.nostr.wine/npub1zqsu3ys4fragn2a5e3lgv69r4rwwhts2fserll402uzr3qeddxfsffcqrs?broadcast=true ... FAIL
[300/648] wss://foton.solarpowe.red/ ... FAIL
[301/648] wss://frens.nostr1.com/ ... FAIL
[302/648] wss://frens.utxo.one/ ... FAIL
[303/648] wss://futarchyhub.com/relay ... FAIL
[304/648] wss://galaxy13.nostr1.com/ ... FAIL
[305/648] wss://garden.zap.cooking/ ... FAIL
[306/648] wss://gleasonator.dev/relay ... OK
[307/648] wss://gm.swarmstr.com/ ... FAIL
[308/648] wss://grace.ikaros.hzrd149.com/ ... FAIL
[309/648] wss://groups.0xchat.com/ ... FAIL
[310/648] wss://haven.dergigi.com/ ... FAIL
[311/648] wss://haven.downisontheup.ca/ ... FAIL
[312/648] wss://haven.slidestr.net/ ... FAIL
[313/648] wss://haven.sovereignengineering.io/outbox ... FAIL
[314/648] wss://hbr.coracle.social/ ... FAIL
[315/648] wss://henhouse.social/relay ... FAIL
[316/648] wss://hodlbod.coracle.social/ ... FAIL
[317/648] wss://hodlbod.coracle.tools/ ... FAIL
[318/648] wss://HodlHarry@nostrcheck.me/ ... OK
[319/648] wss://hole.v0l.io/ ... FAIL
[320/648] wss://hotrightnow.nostr1.com/ ... FAIL
[321/648] wss://https//wot.utxo.one/# ... FAIL
[322/648] wss://inbox.relays.land/ ... FAIL
[323/648] wss://inner.sebastix.social/ ... FAIL
[324/648] wss://iris.to/ ... FAIL
[325/648] wss://kmc-nostr.amiunderwater.com/ ... FAIL
[326/648] wss://knostr.neutrine.com:8880/ ... FAIL
[327/648] wss://knostr.neutrine.com/ ... FAIL
[328/648] wss://kr.purplerelay.com/ ... FAIL
[329/648] wss://librerelay.aaroniumii.com/ ... FAIL
[330/648] wss://ln.weedstr.net/nostrrelay/weedstr ... FAIL
[331/648] wss://lnb.bolverker.com/nostrrelay/666 ... FAIL
[332/648] wss://lockbox.fiatjaf.com/ ... FAIL
[333/648] wss://lv01.tater.ninja/ ... FAIL
[334/648] wss://moonboi.nostrfreaks.com/ ... FAIL
[335/648] wss://muxstr.northwest.io/ ... FAIL
[336/648] wss://nerostr.xmr.rocks/ ... FAIL
[337/648] wss://news.nos.social/ ... FAIL
[338/648] wss://nfdb.noswhere.com/victor ... FAIL
[339/648] wss://nip17.com/ ... FAIL
[340/648] wss://njump.me/ ... FAIL
[341/648] wss://nos.win/ ... FAIL
[342/648] wss://nostr-01.bolt.observer/ ... FAIL
[343/648] wss://nostr-paid.h3z.jp/ ... FAIL
[344/648] wss://nostr-relay.amethyst.name/ ... FAIL
[345/648] wss://nostr-relay.amethyst.name/jade-raven-juliet ... FAIL
[346/648] wss://nostr-relay.feddit.social/ ... FAIL
[347/648] wss://nostr-relay.lnmarkets.com/ ... FAIL
[348/648] wss://nostr-relay.philipcristiano.com/ ... FAIL
[349/648] wss://nostr-relay.rawrapp.workers.dev/ ... FAIL
[350/648] wss://nostr-relay.schnitzel.world/ ... FAIL
[351/648] wss://nostr-relay.untethr.me/ ... FAIL
[352/648] wss://nostr-relay.usebitcoin.space/ ... FAIL
[353/648] wss://nostr-verified.wellorder.net/ ... OK
[354/648] wss://nostr.1sat.org/ ... FAIL
[355/648] wss://nostr.256k1.dev/ ... FAIL
[356/648] wss://nostr.438b.net/ ... FAIL
[357/648] wss://nostr.4rs.nl/ ... OK
[358/648] wss://nostr.8777.ch/ ... FAIL
[359/648] wss://nostr.actn.io/ ... FAIL
[360/648] wss://nostr.app.runonflux.io/ ... OK
[361/648] wss://nostr.at/ ... FAIL
[362/648] wss://nostr.azuki.blue/ember-ivory-mike ... FAIL
[363/648] wss://nostr.azzamo.net/flint-warden ... OK
[364/648] wss://nostr.beckmeyer.us/ ... FAIL
[365/648] wss://nostr.bitcoin.ninja/ ... FAIL
[366/648] wss://nostr.bitcoin.social/ ... FAIL
[367/648] wss://nostr.bostonbtc.com/ ... FAIL
[368/648] wss://nostr.build/ ... FAIL
[369/648] wss://nostr.cizmar.net/ ... FAIL
[370/648] wss://nostr.cloud.vinney.xyz/ ... FAIL
[371/648] wss://nostr.coinfundit.com/ ... FAIL
[372/648] wss://nostr.compile-error.net/ ... FAIL
[373/648] wss://nostr.cx.ms/ ... FAIL
[374/648] wss://nostr.delo.software/ ... FAIL
[375/648] wss://nostr.digitalreformation.info/ ... FAIL
[376/648] wss://nostr.dontyou.click/oscar-warden-flint ... FAIL
[377/648] wss://nostr.drss.io/ ... FAIL
[378/648] wss://nostr.dvdt.dev/ ... FAIL
[379/648] wss://nostr.easify.de/ ... FAIL
[380/648] wss://nostr.easydns.ca/ ... FAIL
[381/648] wss://nostr.extrabits.io/ ... FAIL
[382/648] wss://nostr.fediverse.jp/ ... FAIL
[383/648] wss://nostr.flamingo-mail.com/ ... FAIL
[384/648] wss://nostr.gives.africa/ ... FAIL
[385/648] wss://nostr.gromeul.eu/ ... FAIL
[386/648] wss://nostr.holybea.com/ ... FAIL
[387/648] wss://nostr.huszonegy.world/ ... OK
[388/648] wss://nostr.ingwie.me/ ... FAIL
[389/648] wss://nostr.jiashanlu.synology.me/ ... FAIL
[390/648] wss://nostr.k3tan.com/ ... FAIL
[391/648] wss://nostr.koning-degraaf.nl/ ... FAIL
[392/648] wss://nostr.kosmos.org/ ... FAIL
[393/648] wss://nostr.land/xray-xray-cipher ... FAIL
[394/648] wss://nostr.lnproxy.org/ ... FAIL
[395/648] wss://nostr.malin.onl/ ... FAIL
[396/648] wss://nostr.massmux.com/ ... FAIL
[397/648] wss://nostr.middling.mydns.jp/ ... FAIL
[398/648] wss://nostr.mineracks.com/ ... OK
[399/648] wss://nostr.noones.com/ ... FAIL
[400/648] wss://nostr.novacisko.cz/ ... FAIL
[401/648] wss://nostr.okaits7534.net/ ... FAIL
[402/648] wss://nostr.oldenburg.cool/ ... FAIL
[403/648] wss://nostr.openhoofd.nl/ ... OK
[404/648] wss://nostr.ownscale.org/ ... FAIL
[405/648] wss://nostr.palandi.cloud/ ... FAIL
[406/648] wss://nostr.pareto.town/ ... FAIL
[407/648] wss://nostr.petrkr.net/willow ... FAIL
[408/648] wss://nostr.primz.org/charlie-xenon-bravo ... FAIL
[409/648] wss://nostr.radixrat.com/ ... FAIL
[410/648] wss://nostr.robosats.org/ ... FAIL
[411/648] wss://nostr.sathoarder.com/titan-glyph ... OK
[412/648] wss://nostr.sats.coffee/ ... FAIL
[413/648] wss://nostr.sats.li/ ... FAIL
[414/648] wss://nostr.screaminglife.io/ ... FAIL
[415/648] wss://nostr.sectiontwo.org/ ... FAIL
[416/648] wss://nostr.sg/ ... FAIL
[417/648] wss://nostr.sixteensixtyone.com/ ... FAIL
[418/648] wss://nostr.stoner.com/ ... FAIL
[419/648] wss://nostr.superfriends.online/titan ... OK
[420/648] wss://nostr.supremestack.xyz/ ... FAIL
[421/648] wss://nostr.swiss-enigma.ch/ ... FAIL
[422/648] wss://nostr.terminus.money/ ... FAIL
[423/648] wss://nostr.topeth.info/ ... FAIL
[424/648] wss://nostr.uselessshit.co/ ... FAIL
[425/648] wss://nostr.yuv.al/ ... FAIL
[426/648] wss://nostr.zoel.network/ ... FAIL
[427/648] wss://nostr01.counterclockwise.io/ ... FAIL
[428/648] wss://nostr1.tunnelsats.com/ ... FAIL
[429/648] wss://nostr1676319567170.app.runonflux.io/ ... FAIL
[430/648] wss://nostr21.com/raven ... FAIL
[431/648] wss://nostrcheck.tnsor.network/ ... FAIL
[432/648] wss://nostrelay.circum.space/ ... OK
[433/648] wss://nostrelay.unchainedhome.com/ ... OK
[434/648] wss://nostrex.fly.dev/ ... FAIL
[435/648] wss://nostrrelay.com/ ... FAIL
[436/648] wss://nostrrelay.taylorperron.com/ ... FAIL
[437/648] wss://notify.damus.io/ ... FAIL
[438/648] wss://novoa.nagoya/ ... FAIL
[439/648] wss://now.lol/ ... FAIL
[440/648] wss://npub1spxdug4m3y24hpx5crm0el4zhkk0wafs8kp6m0xu0wecygqej2xqq8gyhx.fips.network/ ... FAIL
[441/648] wss://nsite.run/ ... FAIL
[442/648] wss://nsrelay.assilvestrar.club/ ... FAIL
[443/648] wss://nstr.utn.lol/ ... FAIL
[444/648] wss://nwc.primal.net/c4ib8h2rs62bkmlk4wu7jbko9ugqbn ... FAIL
[445/648] wss://onchain.pub/ ... FAIL
[446/648] wss://paid-relay.nostr.blockhenge.com/ ... FAIL
[447/648] wss://paid.nostrified.org/ ... FAIL
[448/648] wss://pleb.cloud/ ... FAIL
[449/648] wss://primal-cache.mutinywallet.com/v1 ... FAIL
[450/648] wss://prl.plus/ ... OK
[451/648] wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87 ... FAIL
[452/648] wss://proxy.nostr-relay.app/8c5723f2601334234e1922d2e842d6bbf209283b07120b3f1d38660915f13793 ... FAIL
[453/648] wss://public.crostr.com/beacon-umbra ... OK
[454/648] wss://public.relaying.io/ ... FAIL
[455/648] wss://questions.swarmstr.com/ ... FAIL
[456/648] wss://r.f7z.io/ ... FAIL
[457/648] wss://r.kojira.io/ ... FAIL
[458/648] wss://r314y.0xd43m0n.xyz/ ... FAIL
[459/648] wss://ragnar-relay.com/ ... FAIL
[460/648] wss://raley.azzamo.net/ ... FAIL
[461/648] wss://realy.damus.io/ ... FAIL
[462/648] wss://realy.nostr.bg/ ... FAIL
[463/648] wss://relais.noswhere.com/ ... FAIL
[464/648] wss://relay-1.arsip.my.id/ ... FAIL
[465/648] wss://relay-rpi.edufeed.org/ ... OK
[466/648] wss://relay-testnet.k8s.layer3.news/xenon-anchor ... OK
[467/648] wss://relay.2020117.xyz/oscar-lima ... FAIL
[468/648] wss://relay.21mil.me/ ... FAIL
[469/648] wss://relay.8333.space/ ... FAIL
[470/648] wss://relay.angor.io/ ... OK
[471/648] wss://relay.apps.sebdev.io/ ... FAIL
[472/648] wss://relay.artx.market/ ... OK
[473/648] wss://relay.austrich.net/ ... FAIL
[474/648] wss://relay.benthecarman.com/ ... FAIL
[475/648] wss://relay.bitesize-media.com/ ... FAIL
[476/648] wss://relay.bitmapstr.io/ ... FAIL
[477/648] wss://relay.bleskop.com/ ... FAIL
[478/648] wss://relay.braydon.com/ ... FAIL
[479/648] wss://relay.carlos-cdb.top/vertex-whiskey ... OK
[480/648] wss://relay.causes.com/ ... FAIL
[481/648] wss://relay.codl.co/ ... FAIL
[482/648] wss://relay.corpum.com/ ... OK
[483/648] wss://relay.cryptocculture.com/ ... FAIL
[484/648] wss://relay.deezy.io/ ... FAIL
[485/648] wss://relay.despera.space/ ... FAIL
[486/648] wss://relay.devstr.org/ ... FAIL
[487/648] wss://relay.digitalezukunft.cyou/ ... FAIL
[488/648] wss://relay.disobey.dev/ ... FAIL
[489/648] wss://relay.dwadziesciajeden.pl/ ... OK
[490/648] wss://relay.getalby.com/ ... FAIL
[491/648] wss://relay.geyser.fund/ ... FAIL
[492/648] wss://relay.gobrrr.me/ ... FAIL
[493/648] wss://relay.goodmorningbitcoin.com/ ... FAIL
[494/648] wss://relay.gulugulu.moe/ ... OK
[495/648] wss://relay.hangar.ninja/ ... FAIL
[496/648] wss://relay.hash.stream/ ... FAIL
[497/648] wss://relay.highlighter.com/ ... FAIL
[498/648] wss://relay.hodlboard.org/ ... FAIL
[499/648] wss://relay.hunos.hu/ ... FAIL
[500/648] wss://relay.iefan.net/ ... FAIL
[501/648] wss://relay.ingwie.me/ ... FAIL
[502/648] wss://relay.islandbitcoin.com/echo-anchor-cipher ... OK
[503/648] wss://relay.jeffg.fyi/ ... FAIL
[504/648] wss://relay.jerseyplebs.com/ ... FAIL
[505/648] wss://relay.kisiel.net.pl/ ... FAIL
[506/648] wss://relay.lab.rytswd.com/karma-titan-lantern ... OK
[507/648] wss://relay.lacompagniemaximus.com/romeo-yonder ... FAIL
[508/648] wss://relay.layer.systems/ ... FAIL
[509/648] wss://relay.lexingtonbitcoin.org/ ... FAIL
[510/648] wss://relay.lightning.pub/yankee ... OK
[511/648] wss://relay.livefreebtc.dev/ ... FAIL
[512/648] wss://relay.magiccity.live/ ... FAIL
[513/648] wss://relay.mccormick.cx/quartz ... OK
[514/648] wss://relay.mess.ch/ ... FAIL
[515/648] wss://relay.minds.com/nostr/v1/ws ... FAIL
[516/648] wss://relay.mostro.network/ ... OK
[517/648] wss://relay.nextblock.city/ ... FAIL
[518/648] wss://relay.nimo.cash/ ... FAIL
[519/648] wss://relay.nodana.app/ ... FAIL
[520/648] wss://relay.nomus.io/ ... OK
[521/648] wss://relay.nosotros.app/ ... FAIL
[522/648] wss://relay.nostpy.lol/ ... FAIL
[523/648] wss://relay.nostr.ai/ ... FAIL
[524/648] wss://relay.nostr.band/trusted ... FAIL
[525/648] wss://relay.nostr.blockhenge.com/ ... OK
[526/648] wss://relay.nostr.directory/ ... FAIL
[527/648] wss://relay.nostr.moe/ ... FAIL
[528/648] wss://relay.nostr.pub/ ... FAIL
[529/648] wss://relay.nostr.ro/ ... FAIL
[530/648] wss://relay.nostr.scot/ ... FAIL
[531/648] wss://relay.nostr.watch/ ... FAIL
[532/648] wss://relay.nostrarabia.com/ ... FAIL
[533/648] wss://relay.nostrasia.net/ ... FAIL
[534/648] wss://relay.nostreggs.io/ ... FAIL
[535/648] wss://relay.nostrich.land/ ... FAIL
[536/648] wss://relay.nostriches.or/ ... FAIL
[537/648] wss://relay.nostrid.com/ ... FAIL
[538/648] wss://relay.nostronautti.fi/ ... FAIL
[539/648] wss://relay.npubhaus.com/ ... OK
[540/648] wss://relay.nquiz.io/ ... FAIL
[541/648] wss://relay.nsec.app/ ... FAIL
[542/648] wss://relay.nsnip.io/ ... FAIL
[543/648] wss://relay.nstr.cc/ ... FAIL
[544/648] wss://relay.nuts.cash/ ... OK
[545/648] wss://relay.nvote.co/ ... FAIL
[546/648] wss://relay.oke.minds.io/nostr/v1/ws ... FAIL
[547/648] wss://relay.olas.app/ ... FAIL
[548/648] wss://relay.orangepilldev.com/ ... FAIL
[549/648] wss://relay.orangesync.tech/ ... FAIL
[550/648] wss://relay.orly.dev/ ... FAIL
[551/648] wss://relay.piazza.today/ ... FAIL
[552/648] wss://relay.pituf.in/ ... FAIL
[553/648] wss://relay.pleb.one/ ... FAIL
[554/648] wss://relay.plebeian.market/ ... OK
[555/648] wss://relay.pokernostr.com/ ... FAIL
[556/648] wss://relay.qstr.app/ ... OK
[557/648] wss://relay.reya.su/ ... FAIL
[558/648] wss://relay.rip/ ... FAIL
[559/648] wss://relay.rkus.se/ ... FAIL
[560/648] wss://relay.roygbiv.guide/ ... FAIL
[561/648] wss://relay.samt.st/ ... OK
[562/648] wss://relay.satlantis.io/ ... OK
[563/648] wss://relay.sendstr.com/ ... FAIL
[564/648] wss://relay.sharegap.net/ ... OK
[565/648] wss://relay.shawnyeager.com/ ... FAIL
[566/648] wss://relay.shawnyeager.com/chat ... FAIL
[567/648] wss://relay.shawnyeager.com/inbox ... FAIL
[568/648] wss://relay.shawnyeager.com/outbox ... FAIL
[569/648] wss://relay.shawnyeager.com/private ... FAIL
[570/648] wss://relay.shosho.live/ ... FAIL
[571/648] wss://relay.siamdev.cc/ ... FAIL
[572/648] wss://relay.snort.social:57/ ... FAIL
[573/648] wss://relay.sovereign.app/ ... FAIL
[574/648] wss://relay.sovrgn.co.za/ ... OK
[575/648] wss://relay.thibautduchene.fr/ ... FAIL
[576/648] wss://relay.tools/ ... FAIL
[577/648] wss://relay.towardsliberty.com/ ... FAIL
[578/648] wss://relay.tunestr.io/ ... FAIL
[579/648] wss://relay.utxo.one/chat ... FAIL
[580/648] wss://relay.utxo.one/inbox ... FAIL
[581/648] wss://relay.utxo.one/private ... FAIL
[582/648] wss://relay.valera.co/ ... FAIL
[583/648] wss://relay.varke.eu/ ... FAIL
[584/648] wss://relay.verified-nostr.com/ ... FAIL
[585/648] wss://relay.virginiafreedom.tech/ ... FAIL
[586/648] wss://relay.wavefunc.live/ ... OK
[587/648] wss://relay.wisp.talk/ ... OK
[588/648] wss://relay.xavierdamman.com/ember-quartz ... FAIL
[589/648] wss://relay.ynniv.com/ ... FAIL
[590/648] wss://relay.zap.stream/ ... FAIL
[591/648] wss://relay.zeh.app/ ... FAIL
[592/648] wss://relay.zhoushen929.com/ ... FAIL
[593/648] wss://relay1.orangesync.tech/ ... FAIL
[594/648] wss://relay2.blogstr.app/ ... FAIL
[595/648] wss://relay2.nostrasia.net/ ... FAIL
[596/648] wss://relay2.orangesync.tech/ ... FAIL
[597/648] wss://relay2.sovereignengineering.io/ ... FAIL
[598/648] wss://relay3.openvine.co/victor ... OK
[599/648] wss://ribo.us.nostria.app/ ... OK
[600/648] wss://rsslay.fiatjaf.com/ ... FAIL
[601/648] wss://rsslay.nostr.moe/ ... FAIL
[602/648] wss://rsslay.wss//relay.nostr.info%0b%20nostr.net ... FAIL
[603/648] wss://ryan.nostr1.com/ ... FAIL
[604/648] wss://santo.iguanatech.net/ ... FAIL
[605/648] wss://satdow.relaying.io/ ... FAIL
[606/648] wss://satstacker.cloud/ ... FAIL
[607/648] wss://sgl.rustcorp.com.au/ ... FAIL
[608/648] wss://snort.social/ ... FAIL
[609/648] wss://soloco.nl/ ... OK
[610/648] wss://srtrelay.c-stellar.net/ ... FAIL
[611/648] wss://srtrelay.c-stellar.net/karma-oscar-lima ... FAIL
[612/648] wss://strfry.bonsai.com/ivory-xenon-juliet ... OK
[613/648] wss://strfry.chatbett.de/ ... FAIL
[614/648] wss://strfry.iris.to/ ... FAIL
[615/648] wss://strfry.openhoofd.nl/ ... OK
[616/648] wss://student.chadpolytechnic.com/ ... FAIL
[617/648] wss://swiss.nostr.lc/ ... FAIL
[618/648] wss://testrelay.nostr1.com/ ... FAIL
[619/648] wss://thecitadel.nostr1.com/sierra ... FAIL
[620/648] wss://thewildhustle.nostr1.com/ ... FAIL
[621/648] wss://tijl.xyz/ ... FAIL
[622/648] wss://tmp-relay.cesc.trade/ ... FAIL
[623/648] wss://tw.purplerelay.com/ ... FAIL
[624/648] wss://umbrel.local:4848/ ... FAIL
[625/648] wss://umbrel.tail716804.ts.net:4848/ ... FAIL
[626/648] wss://universe.nostrich.land/ ... FAIL
[627/648] wss://us.azzamo.net/ ... FAIL
[628/648] wss://us.nostr.wine/ ... FAIL
[629/648] wss://us.purplerelay.com/ ... FAIL
[630/648] wss://us.rbr.bio/ ... FAIL
[631/648] wss://user.kindpag.es/ ... FAIL
[632/648] wss://wbc.nostr1.com/ember-xenon-whiskey ... FAIL
[633/648] wss://wons.calva.dev/ ... FAIL
[634/648] wss://wot.azzamo.net/ ... FAIL
[635/648] wss://wot.brightbolt.net/ ... FAIL
[636/648] wss://wot.codingarena.top/ ... FAIL
[637/648] wss://wot.dergigi.com/ ... FAIL
[638/648] wss://wot.sudoscarlos.com/ ... FAIL
[639/648] wss://wot.swarmstr.com/ ... FAIL
[640/648] wss://wot.tealeaf.dev/ ... FAIL
[641/648] wss://wot.zacoos.com/ ... FAIL
[642/648] wss://wotr.relatr.xyz/ ... FAIL
[643/648] wss://xn--%20wss-hn6d//nostr-pub.wellorder.net ... FAIL
[644/648] wss://yarnlady.21mil.me/ ... FAIL
[645/648] wss://yestr.me/ ... FAIL
[646/648] wss://ynostr.yael.at/tango ... FAIL
[647/648] wss://ynostr.yael.at/victor ... FAIL
[648/648] ww://relay.stoner.com ... FAIL
=== Summary ===
Total: 648
Success: 95
Failed: 553
Full results: /home/user/lt/client/tests/broadcast-relay-results.txt

2593
tests/relays.json Normal file

File diff suppressed because it is too large Load Diff

1
tests/test-key.txt Normal file
View File

@@ -0,0 +1 @@
539ac367da733f455a703242efeafbbb0750dda352e9a641e2ccc7aadb5a3e94

View File

@@ -479,6 +479,62 @@ import { initPostCards } from './js/post-interactions2.mjs';
divFooterRight.textContent = `${posts.length} posts`;
}
function prependPostCard(post) {
if (!post?.id || renderedPostIds.has(post.id)) return false;
// If feed is empty or not yet rendered, fall back to full rebuild.
if (divFeed.children.length === 0 || renderedPostIds.size === 0) {
renderFeed();
return false;
}
const postCreatedAt = post.created_at || 0;
// Find the correct insertion point by scanning visible children's created_at.
// Posts are displayed newest-first, so we insert before the first child
// that is older than (or equal to) the new post.
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: autoplayVideosEnabled });
let inserted = false;
for (const child of divFeed.children) {
const childId = child.getAttribute('data-post-id');
if (!childId) continue;
const childPost = posts.find((p) => p.id === childId);
if (!childPost) continue;
const childCreatedAt = childPost.created_at || 0;
if (postCreatedAt >= childCreatedAt) {
divFeed.insertBefore(postEl, child);
inserted = true;
break;
}
}
if (!inserted) {
// Post is older than all visible posts — append at the bottom.
divFeed.appendChild(postEl);
}
renderedPostIds.add(post.id);
cards.wireInteractions([post.id], { currentPubkey });
// Trim DOM to displayCount.
while (divFeed.children.length > displayCount) {
const lastChild = divFeed.lastElementChild;
if (!lastChild) break;
const removedId = lastChild.getAttribute('data-post-id');
if (removedId) renderedPostIds.delete(removedId);
divFeed.removeChild(lastChild);
}
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
divHint.textContent = followedPubkeys.length > 0
? `${posts.length} posts from ${followedPubkeys.length} follows`
: 'Follow users to populate your feed.';
divFooterRight.textContent = `${posts.length} posts`;
return true;
}
async function fetchFeedWindow(authors, { since, limit = FEED_BOOTSTRAP_WINDOW_LIMIT, renderAfterCache = false } = {}) {
if (!Array.isArray(authors) || authors.length === 0) return;
const filters = { kinds: [1], authors, limit };
@@ -499,7 +555,11 @@ import { initPostCards } from './js/post-interactions2.mjs';
(Array.isArray(fresh) ? fresh : []).forEach((evt1) => {
upsertFeedPost(evt1);
});
if (posts.length > 0) {
// Only do a full re-render if the feed hasn't been rendered yet.
// If the feed is already rendered, the posts are in the array and
// will appear when the user clicks "See More" or on next full render.
// This prevents the relay fetch callback from wiping prepended live posts.
if (posts.length > 0 && renderedPostIds.size === 0) {
renderFeed();
}
}).catch(() => {});
@@ -710,7 +770,11 @@ import { initPostCards } from './js/post-interactions2.mjs';
return;
}
if (!upsertFeedPost(evt)) return;
renderFeed();
if (renderedPostIds.size > 0 && divFeed.children.length > 0) {
prependPostCard(evt);
} else {
renderFeed();
}
}
});

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.81",
"VERSION_NUMBER": "0.7.81",
"BUILD_DATE": "2026-06-30T14:55:04.841Z"
"VERSION": "v0.7.86",
"VERSION_NUMBER": "0.7.86",
"BUILD_DATE": "2026-07-01T13:22:33.456Z"
}

View File

@@ -6215,6 +6215,9 @@ async function handlePublish(requestId, event, port) {
// Track current batch for progress display. Updated by the batch loop.
let currentBatch = 0;
let totalBatches = 0;
// Debug: track publish start time for timing analysis (defined in outer
// scope so the batch loop and silent-failure detection can access it).
const publishStartTime = Date.now();
if (isBroadcast && totalTarget > 0) {
console.log(`[Worker] Broadcasting to ${allBroadcastUrls.length} relays ` +
@@ -6236,8 +6239,10 @@ async function handlePublish(requestId, event, port) {
ndkEvent.on('relay:published', (relay) => {
const url = relay?.url || String(relay || '');
if (!url) return;
const elapsed = ((Date.now() - publishStartTime) / 1000).toFixed(1);
publishedSoFar.add(url);
trackRelayWrite(url);
console.log(`[Worker][broadcast] ✅ ${url} published (${elapsed}s) — batch ${currentBatch}/${totalBatches}, ${publishedSoFar.size}/${totalTarget} total`);
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
@@ -6255,11 +6260,14 @@ async function handlePublish(requestId, event, port) {
const url = relay?.url || String(relay || '');
if (!url) return;
const errMsg = err?.message || String(err) || '';
const elapsed = ((Date.now() - publishStartTime) / 1000).toFixed(1);
const isTimeout = /timeout/i.test(errMsg);
if (isTimeout) {
timedOutSoFar.add(url);
console.log(`[Worker][broadcast] ⏱️ ${url} TIMEOUT (${elapsed}s) — batch ${currentBatch}/${totalBatches}`);
} else {
failedSoFar.add(url);
console.log(`[Worker][broadcast] ❌ ${url} REJECTED (${elapsed}s): ${errMsg} — batch ${currentBatch}/${totalBatches}`);
}
broadcast({
type: 'broadcastProgress',
@@ -6348,6 +6356,8 @@ async function handlePublish(requestId, event, port) {
for (let bi = 0; bi < batches.length; bi++) {
currentBatch = bi + 1; // update before publish so per-relay events include it
const batch = batches[bi];
const batchStartTime = Date.now();
console.log(`[Worker][broadcast] Batch ${bi + 1}/${totalBatches} starting — ${batch.length} relays: ${batch.slice(0, 5).join(', ')}${batch.length > 5 ? '...' : ''}`);
const batchSet = NDKRelaySet.fromRelayUrls(batch, ndk);
if (batchSet?.relays) {
for (const relay of batchSet.relays) {
@@ -6363,6 +6373,19 @@ async function handlePublish(requestId, event, port) {
} catch (batchErr) {
// Expected — some relays in this batch may fail.
}
// --- Detect silent failures ---
// NDK's relay.publish() can resolve false without emitting
// relay:publish:failed (e.g., DNS errors, connection refused,
// HTTP handshake failures). These relays are not in any of
// our tracked sets. Mark them as timed out so they get
// skip-marked and don't waste time on the next publish.
for (const url of batch) {
if (!publishedSoFar.has(url) && !failedSoFar.has(url) && !timedOutSoFar.has(url)) {
timedOutSoFar.add(url);
const elapsed = ((Date.now() - publishStartTime) / 1000).toFixed(1);
console.log(`[Worker][broadcast] ⏱️ ${url} SILENT TIMEOUT (${elapsed}s) — batch ${currentBatch}/${totalBatches} (no response, likely connection failure)`);
}
}
// Emit a progress event with batch info after each batch.
broadcast({
type: 'broadcastProgress',
@@ -6375,7 +6398,8 @@ async function handlePublish(requestId, event, port) {
batchCurrent: bi + 1,
batchTotal: totalBatches,
});
console.log(`[Worker] Batch ${bi + 1}/${totalBatches} complete — ` +
const batchElapsed = ((Date.now() - batchStartTime) / 1000).toFixed(1);
console.log(`[Worker] Batch ${bi + 1}/${totalBatches} complete (${batchElapsed}s) — ` +
`${publishedSoFar.size}/${totalTarget} total succeeded, ` +
`${failedSoFar.size + timedOutSoFar.size} failed/timeout so far`);
}

View File

@@ -652,6 +652,68 @@ import { initPostCards } from './js/post-interactions2.mjs';
divFooterRight.textContent = `${posts.length} posts`;
}
function prependPostCard(post) {
if (!post?.id || renderedPostIds.has(post.id)) return false;
// Event mode should never prepend — it uses renderSingleEvent / renderFeed.
if (isEventMode) {
renderFeed();
return false;
}
// If feed is empty or not yet rendered, fall back to full rebuild.
if (divFeed.children.length === 0 || renderedPostIds.size === 0) {
renderFeed();
return false;
}
const postCreatedAt = post.created_at || 0;
// Find the correct insertion point by scanning visible children's created_at.
// Posts are displayed newest-first, so we insert before the first child
// that is older than (or equal to) the new post.
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: false });
let inserted = false;
for (const child of divFeed.children) {
const childId = child.getAttribute('data-post-id');
if (!childId) continue;
const childPost = posts.find((p) => p.id === childId);
if (!childPost) continue;
const childCreatedAt = childPost.created_at || 0;
if (postCreatedAt >= childCreatedAt) {
divFeed.insertBefore(postEl, child);
inserted = true;
break;
}
}
if (!inserted) {
// Post is older than all visible posts — append at the bottom.
divFeed.appendChild(postEl);
}
renderedPostIds.add(post.id);
cards.wireInteractions([post.id], { currentPubkey });
// Trim DOM to displayCount.
while (divFeed.children.length > displayCount) {
const lastChild = divFeed.lastElementChild;
if (!lastChild) break;
const removedId = lastChild.getAttribute('data-post-id');
if (removedId) renderedPostIds.delete(removedId);
divFeed.removeChild(lastChild);
}
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
divHint.textContent = posts.length > 0
? `${posts.length} posts`
: (isProfileMode ? 'No posts yet.' : 'No posts yet — write one above.');
divFooterRight.textContent = `${posts.length} posts`;
return true;
}
function renderSingleEvent(evt) {
if (!evt?.id) return;
posts.length = 0;
@@ -909,7 +971,11 @@ import { initPostCards } from './js/post-interactions2.mjs';
}
if (!upsertPost(evt)) return;
renderFeed();
if (renderedPostIds.size > 0 && divFeed.children.length > 0) {
prependPostCard(evt);
} else {
renderFeed();
}
});
if (isEventMode) {