Files
bitvid/js/nostr/relayBatchFetcher.js
T
thePR0M3TH3ANandClaude Opus 4.8 55bd181867 fix(nostr): bound read/subscribe relay set to stop cold-login REQ storm
On cold login the app opened WebSockets to a user's full NIP-65 read set
(in prod often ~20 relays, most dead). The flood of failing connections
churned the main thread and starved the nip-07 extension's postMessage
round-trips, so encrypted lists (blocks, subscriptions, hashtags) were
fetched but timed out during decryption ("message channel closed",
"Decryption timed out").

Bound every read/subscribe path to MAX_SUBSCRIBE_RELAYS=8 via a shared,
test-aware capReadRelays helper, applied at the chokepoints:
- client.js applyRelayPreferences (this.relays / this.readRelays)
- relayBatchFetcher.js fetchListIncrementally (blocks, subs, hashtags)
Writes stay uncapped (publish reach preserved).

capReadRelays is user-relays-first (a user's own relays are where their
data authoritatively lives, and small fast-path limits would otherwise
crowd them out — the regression that reverted the first attempt) while
reserving RESERVED_DEFAULT_RELAY_SLOTS=2 for reliable default aggregators
so the "~20 personal relays, all dead" case still reaches a live relay.
Under test the reserved core resolves to the test-relay override, keeping
harnesses isolated.

Tests: new relay-subscribe-cap coverage (bounded + reserved-defaults +
writes-uncapped); spec-corrected user-blocks, subscriptions-manager and
nostr/client fetch tests to bounded (not exact) relay fan-out while
keeping all behavioral/security assertions (per-relay coverage, correct
decrypt, block hydration, dedup) intact. See TEST_INTEGRITY.md and
KNOWN_BUGS #0/#4. Full unit + dm:unit suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-14 22:51:36 -04:00

257 lines
8.8 KiB
JavaScript

import { isDevMode } from "../config.js";
import { devLogger } from "../utils/logger.js";
import { withRequestTimeout } from "../utils/asyncUtils.js";
import { normalizeNostrPubkey, sanitizeRelayList } from "./nip46Client.js";
import { DEFAULT_RELAY_URLS, RELAY_URLS, capReadRelays } from "./toolkit.js";
import { STANDARD_TIMEOUT_MS } from "../constants.js";
export class RelayBatchFetcher {
constructor(client) {
this.client = client;
}
async fetchListIncrementally({
kind,
pubkey,
dTag,
relayUrls,
fetchFn,
since,
timeoutMs = STANDARD_TIMEOUT_MS,
maxRelays = 8,
} = {}) {
if (!kind || !pubkey) {
throw new Error("fetchListIncrementally requires kind and pubkey");
}
const normalizedPubkey = normalizeNostrPubkey(pubkey);
if (!normalizedPubkey) {
throw new Error("Invalid pubkey for fetchListIncrementally");
}
// Bound the candidate set to a healthy core that ALWAYS includes the
// reliable defaults. Callers pass a user's full ~20-relay NIP-65 list (most
// dead); on a cold load none are marked unhealthy yet, so without this the
// fetcher would pick the first-N dead relays and the flood of failed
// connections would destabilize the nip-07 extension and time out list
// decryption. (See KNOWN_BUGS #0.)
const relaysToUse = capReadRelays(
Array.isArray(relayUrls) && relayUrls.length
? relayUrls
: this.client.relays,
);
const sanitizedRequestedRelays = sanitizeRelayList(relaysToUse);
const healthyCandidates = this.client.getHealthyRelays(sanitizedRequestedRelays);
const healthySet = new Set(healthyCandidates);
const remainingRequested = sanitizedRequestedRelays.filter(
(relayUrl) => !healthySet.has(relayUrl),
);
let relayCandidates = [...healthyCandidates, ...remainingRequested];
if (!healthyCandidates.length && relaysToUse.length) {
const defaultFallback = sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
relayCandidates = sanitizedRequestedRelays.length
? sanitizedRequestedRelays
: defaultFallback.length
? defaultFallback
: sanitizeRelayList(Array.from(RELAY_URLS));
devLogger.warn(
"[fetchListIncrementally] Healthy relays exhausted; using fallback relay list for one-off fetch.",
{
requested: relaysToUse,
fallback: relayCandidates,
},
);
}
const readPreferences = new Set(Array.isArray(this.client.readRelays) ? this.client.readRelays : []);
// Sort relays: prefer user's read relays first
const sortedRelays = [...relayCandidates].sort((a, b) => {
const aPreferred = readPreferences.has(a);
const bPreferred = readPreferences.has(b);
const aHealthy = healthySet.has(a);
const bHealthy = healthySet.has(b);
if (aHealthy && !bHealthy) return -1;
if (!aHealthy && bHealthy) return 1;
if (aPreferred && !bPreferred) return -1;
if (!aPreferred && bPreferred) return 1;
return 0;
});
// Cap the number of relays to avoid excessive requests
const relayCap = Number.isFinite(maxRelays)
? Math.max(1, Math.floor(maxRelays))
: 8;
const cappedRelays = sortedRelays.slice(0, relayCap);
const normalizedRelays = sanitizeRelayList(cappedRelays);
if (isDevMode) {
devLogger.log("[fetchListIncrementally] Selected relays:", {
count: normalizedRelays.length,
relays: normalizedRelays,
});
}
const results = [];
const concurrencyLimit = 8;
// We'll use the pool's list method if fetchFn isn't provided,
// but we need to call it per-relay.
const pool = await this.client.ensurePool();
const actualFetchFn = typeof fetchFn === "function"
? fetchFn
: (r, f, timeout) => pool.list([r], [f], { timeout });
const chunks = [];
for (let i = 0; i < normalizedRelays.length; i += concurrencyLimit) {
chunks.push(normalizedRelays.slice(i, i + concurrencyLimit));
}
let anySuccess = false;
const failures = [];
for (const chunk of chunks) {
const promises = chunk.map(async (relayUrl) => {
let lastSeen = this.client.getSyncLastSeen(
kind,
normalizedPubkey,
dTag,
relayUrl,
);
// If an explicit 'since' override is provided, use it instead of storage.
// This allows services to anchor fetching to their own known state.
if (since !== undefined) {
const overrideSince = Number(since);
lastSeen =
Number.isFinite(overrideSince) && overrideSince >= 0
? Math.floor(overrideSince)
: 0;
}
const filter = {
kinds: [kind],
authors: [normalizedPubkey],
};
if (dTag) {
filter["#d"] = [dTag];
}
// Strategy:
// If we have a lastSeen, ask for since = lastSeen + 1.
// If that fails or returns nothing, we don't necessarily fall back
// unless there's an error.
// Wait, requirements say:
// "If metadata is missing for a relay → do a full fetch"
// "If a relay returns an error ... fall back to full fetch"
let doFullFetch = true;
if (lastSeen > 0) {
filter.since = lastSeen + 1;
doFullFetch = false;
}
// Allow callers to override list fetch timeouts without changing non-list query behavior.
const listTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : STANDARD_TIMEOUT_MS;
try {
// Wrap fetch with a timeout to prevent hanging on slow relays
let events = await withRequestTimeout(
actualFetchFn(relayUrl, filter, listTimeoutMs),
listTimeoutMs,
null,
`Fetch from ${relayUrl} timed out`
);
// If incremental returned empty, we assume no updates (success).
// But if we did a full fetch (no since), empty means empty.
if (!events || !Array.isArray(events)) {
events = [];
}
// On success, update lastSeen with max created_at
let maxCreated = 0;
for (const ev of events) {
if (ev.created_at > maxCreated) {
maxCreated = ev.created_at;
}
}
if (maxCreated > 0) {
// Only update if we found something newer or if we did a full fetch
// Actually, if we did incremental and found new stuff, maxCreated > lastSeen.
// If we did full fetch, maxCreated is the latest.
this.client.updateSyncLastSeen(kind, normalizedPubkey, dTag, relayUrl, maxCreated);
}
return { events, ok: true };
} catch (error) {
// Fallback to full fetch if incremental failed or if relay error
if (!doFullFetch) {
try {
delete filter.since;
const events = await withRequestTimeout(
actualFetchFn(relayUrl, filter, listTimeoutMs),
listTimeoutMs,
null,
`Full fetch fallback from ${relayUrl} timed out`
);
// Update lastSeen on success of fallback
let maxCreated = 0;
if (Array.isArray(events)) {
for (const ev of events) {
if (ev.created_at > maxCreated) {
maxCreated = ev.created_at;
}
}
}
if (maxCreated > 0) {
this.client.updateSyncLastSeen(kind, normalizedPubkey, dTag, relayUrl, maxCreated);
}
return { events: events || [], ok: true };
} catch (fallbackError) {
devLogger.warn(`[fetchListIncrementally] Full fetch fallback failed for ${relayUrl}`, fallbackError);
return { error: fallbackError, ok: false };
}
} else {
devLogger.warn(`[fetchListIncrementally] Fetch failed for ${relayUrl}`, error);
this.client.markRelayUnreachable(relayUrl);
return { error, ok: false };
}
}
});
const chunkResults = await Promise.all(promises);
for (const res of chunkResults) {
if (res.ok) {
anySuccess = true;
if (Array.isArray(res.events)) {
results.push(...res.events);
}
} else {
failures.push(res.error);
}
}
}
if (!anySuccess && normalizedRelays.length > 0) {
const error = new Error("All relays failed to fetch list.");
error.code = "fetch-failed";
error.failures = failures;
throw error;
}
// Deduplicate by ID
const uniqueEvents = new Map();
for (const ev of results) {
if (ev && ev.id) {
uniqueEvents.set(ev.id, ev);
}
}
return Array.from(uniqueEvents.values());
}
}