mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 01:12:39 +00:00
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]>
257 lines
8.8 KiB
JavaScript
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());
|
|
}
|
|
}
|