diff --git a/js/nostr/client.js b/js/nostr/client.js index 63df482a..daed3164 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -228,6 +228,29 @@ import { import { inferMimeTypeFromUrl } from "../utils/mime.js"; import { VideoEventBuffer } from "./videoEventBuffer.js"; +// Max relays the client will SUBSCRIBE/read from at once. Bounds the REQ +// fan-out regardless of how large a user's NIP-65 relay list is. Writes are not +// capped (see applyRelayPreferences). Interim until RelayHealth ranks relays by +// liveness — see docs/architecture-refactor.md. +const MAX_SUBSCRIBE_RELAYS = 6; + +// Return at most MAX_SUBSCRIBE_RELAYS urls, prioritizing the bundled known-good +// defaults (which are large, reliable aggregators) ahead of arbitrary NIP-65 +// entries, then filling with the remaining urls in their original order. +function capSubscribeRelays(urls) { + const list = Array.isArray(urls) ? urls.filter((u) => typeof u === "string" && u) : []; + if (list.length <= MAX_SUBSCRIBE_RELAYS) { + return Array.from(new Set(list)); + } + const defaults = new Set(DEFAULT_RELAY_URLS); + const prioritized = []; + const rest = []; + for (const url of list) { + (defaults.has(url) ? prioritized : rest).push(url); + } + return Array.from(new Set([...prioritized, ...rest])).slice(0, MAX_SUBSCRIBE_RELAYS); +} + function normalizeProfileFromEvent(event) { if (!event || !event.content) return null; try { @@ -861,9 +884,22 @@ export class NostrClient { : effectiveAll ); - this.relays = effectiveAll.length ? effectiveAll : Array.from(RELAY_URLS); - this.readRelays = sanitizedRead.length ? sanitizedRead : Array.from(this.relays); - this.writeRelays = sanitizedWrite.length ? sanitizedWrite : Array.from(this.relays); + // Cap the SUBSCRIPTION/read set. A user's NIP-65 list can carry ~20 relays, + // most dead; subscribing on all of them is the dominant REQ-storm amplifier + // (every subsystem fans every query across all relays and re-fires on each + // flaky-relay reconnect). Reads go to a bounded core (known-good defaults + // prioritized); WRITES still fan out to the full set so publish reach is + // unaffected. This is the interim trim until RelayHealth ranks by liveness + // (see docs/architecture-refactor.md, P1). + const cappedRead = capSubscribeRelays(effectiveAll); + + this.relays = cappedRead.length ? cappedRead : Array.from(RELAY_URLS); + this.readRelays = sanitizedRead.length + ? capSubscribeRelays(sanitizedRead) + : Array.from(this.relays); + this.writeRelays = sanitizedWrite.length + ? sanitizedWrite + : Array.from(effectiveAll.length ? effectiveAll : RELAY_URLS); } getWatchHistoryCacheTtlMs() { diff --git a/tests/relay-subscribe-cap.test.mjs b/tests/relay-subscribe-cap.test.mjs new file mode 100644 index 00000000..b00876ae --- /dev/null +++ b/tests/relay-subscribe-cap.test.mjs @@ -0,0 +1,57 @@ +// Scenario (SCN-relay-subscribe-cap): +// Given a user's NIP-65 relay list with many relays (the real-env case had +// ~20, most dead), +// When the client applies those relay preferences, +// Then the SUBSCRIBE/read set is capped to a small bounded core (so query +// fan-out can't explode and re-storm on reconnect), +// And the WRITE set is NOT capped (publish reach is preserved), +// And the bundled known-good defaults are prioritized into the read set. + +import "./test-helpers/setup-localstorage.mjs"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { nostrClient } = await import("../js/nostrClientFacade.js"); +const { DEFAULT_RELAY_URLS } = await import("../js/nostr/toolkit.js"); + +test("subscribe/read relays are capped; write relays are not", () => { + const many = [ + "wss://dead1.example", + "wss://dead2.example", + "wss://dead3.example", + "wss://dead4.example", + "wss://dead5.example", + "wss://dead6.example", + "wss://dead7.example", + "wss://dead8.example", + ...DEFAULT_RELAY_URLS, // known-good, listed last on purpose + ]; + + nostrClient.applyRelayPreferences({ all: many, write: many }); + + // Read/subscribe set is bounded. + assert.ok( + nostrClient.relays.length <= 6, + `read set should be capped (<=6), got ${nostrClient.relays.length}`, + ); + assert.ok( + nostrClient.readRelays.length <= 6, + `readRelays should be capped (<=6), got ${nostrClient.readRelays.length}`, + ); + + // Known-good defaults are prioritized into the capped read set despite being + // listed last in the input. + for (const url of DEFAULT_RELAY_URLS) { + assert.ok( + nostrClient.relays.includes(url), + `known-good default ${url} should survive the cap`, + ); + } + + // Writes are NOT capped — publish reach preserved. + assert.equal( + nostrClient.writeRelays.length, + new Set(many).size, + "write set must include every configured relay", + ); +});