aboutsummaryrefslogtreecommitdiffstats
path: root/src/nostr.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-02 09:26:55 -0700
committerAdam Soltys <[email protected]>2026-11-02 09:26:55 -0700
commit42a76a883d23221cd73e10d8649d8ca28f6aed39 (patch)
treef0d60ed755dc45e51312a3c5f8e8e0207618a284 /src/nostr.js
parent84460457619857ea6cb678bb0a7b2918c222014a (diff)
downloadcoinosv3-42a76a883d23221cd73e10d8649d8ca28f6aed39.tar.gz
coinosv3-42a76a883d23221cd73e10d8649d8ca28f6aed39.zip
The feed asks the relays for less, and asks it in fewer REQs. Measured from the laptop (2026-11-02, after the resume fix), an open of Following sent some 560 REQs in twenty seconds across 57 relays, over a hundred each to damus and primal, and damus answered the home IP "banned: too many rate-limit violations". Four sources, all cut: a quoted note's author (a face tapped, a reply's target) had their relay list asked for one REQ per author per relay — six quotes on screen sent eighteen — and those asks now wait 80 ms and go out as one REQ through the feed's batched fetch, which also remembers who has no list; the live subscriptions were closed and reopened at the end of every refresh pass (seventeen REQs over eight relays, twice per open) and now stay up while the relays and authors they cover are unchanged, the head they count fresh posts against read at each event; the follows' reports were fetched afresh at every reload (an in-memory clock) and now honour the clock the device saved, asking only for what came since; and every one-shot REQ was held open on our own relay until the slowest relay in the ask had answered — the library closes them all together — so the fast one carried each for up to five seconds. One-shot queries now go out per relay (queryOn, queryStreamOn, queryManyOn, the profile and inbox lookups), each closed on that relay's own EOSE, with no more than ten open on any relay at once: the rest wait their turn, and a turn that comes after the ask's own wait is skipped. tools/relay-budget-test.js runs the feed against a fast and a slow fake relay and checks each of these
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6TFgisGKUDhdStLUvs
Diffstat (limited to 'src/nostr.js')
-rw-r--r--src/nostr.js94
1 files changed, 72 insertions, 22 deletions
diff --git a/src/nostr.js b/src/nostr.js
index f0b47d1..731c076 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -407,12 +407,77 @@ async function authRelay(relay, onauth) {
}
return relay.auth(onauth);
}
+// ---- one-shot reads: per relay, closed on its own EOSE, a few at a time --
+// The pool's own querySync keeps every relay's REQ open until the SLOWEST
+// relay has answered or the wait runs out: asked beside a relay still
+// dialing, our own relay — done in fifty milliseconds — carried each REQ
+// for five seconds, and a feed's opening asks stacked up on it by the
+// dozen. Each relay's REQ now closes on that relay's EOSE. And no relay
+// carries more than REQ_SLOTS of them at once: the rest wait their turn,
+// which is a beat on a relay that answers and no longer than the wait on
+// one that doesn't. (A Following feed opened from the laptop sent some 560
+// REQs in twenty seconds across 57 relays, over a hundred each to damus
+// and primal; damus answered the home IP "banned: too many rate-limit
+// violations" on 2026-11-02, and primal notices past twenty at once.)
+const REQ_SLOTS = 10;
+const slots = new Map(); // normalized url -> { n, q: [resolve] }
+function takeSlot(url) {
+ const k = relayKey(url);
+ let s = slots.get(k);
+ if (!s) slots.set(k, (s = { n: 0, q: [] }));
+ if (s.n < REQ_SLOTS) { s.n++; return Promise.resolve(false); }
+ const at = Date.now();
+ return new Promise((resolve) => s.q.push(() => resolve(Date.now() - at))); // how long it waited
+}
+function freeSlot(url) {
+ const s = slots.get(relayKey(url));
+ if (!s) return;
+ const next = s.q.shift();
+ if (next) next(); // the slot passes straight to the next in line
+ else s.n--;
+}
+export const reqSlotsOf = (url) => { const s = slots.get(relayKey(url)); return s ? { busy: s.n, waiting: s.q.length } : { busy: 0, waiting: 0 }; };
+// One REQ carrying `filters` on one relay. Settles at the relay's EOSE, its
+// CLOSED, a failed dial, or maxWait — whichever is first — and sends CLOSE.
+function askRelay(url, filters, maxWait, onEvent) {
+ return new Promise((resolve) => {
+ let finished = false, sub = null, timer = 0;
+ const finish = () => {
+ if (finished) return;
+ finished = true;
+ clearTimeout(timer);
+ if (sub) { try { sub.close('done'); } catch {} }
+ freeSlot(url);
+ resolve();
+ };
+ takeSlot(url).then((waited) => {
+ // a turn that came only after the ask's own wait had run out: the
+ // caller has moved on, and a storm of late REQs helps nobody
+ if (waited > maxWait) { finish(); return; }
+ timer = setTimeout(finish, maxWait + 1000);
+ try {
+ sub = pool.subscribeMap(filters.map((filter) => ({ url, filter })), { maxWait, onevent: onEvent, oneose: finish, onclose: finish });
+ if (finished) { try { sub.close('done'); } catch {} }
+ } catch { finish(); }
+ });
+ });
+}
+// All relays asked at once; an event is counted once however many hold it.
+async function askRelays(relays, filters, maxWait, onEvent) {
+ const seen = new Set();
+ await Promise.all(relays.map((url) => askRelay(url, filters, maxWait, (ev) => {
+ if (!ev || seen.has(ev.id)) return;
+ seen.add(ev.id);
+ onEvent(ev);
+ })));
+}
export async function queryOn(relays, filter, maxWait = 1500) {
probeRelays(relays);
try {
const live = readRelays(relays);
if (!live.length) return [];
- const evs = await pool.querySync(live, filter, { maxWait });
+ const evs = [];
+ await askRelays(live, [filter], maxWait, (ev) => evs.push(ev));
const oks = await verifyEventsAsync(evs);
return evs.filter((_, i) => oks[i]);
} catch { return []; }
@@ -517,17 +582,8 @@ export async function queryManyOn(relays, filters, maxWait = 1500) {
try {
const live = readRelays(relays);
if (!live.length || !filters.length) return [];
- const evs = await new Promise((resolve) => {
- const got = [];
- let sub = null, done = false;
- const finish = () => { if (!done) { done = true; resolve(got); } };
- sub = pool.subscribeMap(live.flatMap((url) => filters.map((filter) => ({ url, filter }))), {
- maxWait,
- onevent: (e) => got.push(e),
- oneose: () => { try { sub?.close('closed automatically on eose'); } catch {} finish(); },
- onclose: finish,
- });
- });
+ const evs = [];
+ await askRelays(live, filters, maxWait, (ev) => evs.push(ev));
const oks = await verifyEventsAsync(evs);
return evs.filter((_, i) => oks[i]);
} catch { return []; }
@@ -540,14 +596,8 @@ export async function queryStreamOn(relays, filter, onEvent, maxWait = 1500) {
try {
const live = readRelays(relays);
if (!live.length) return;
- await new Promise((resolve) => {
- pool.subscribeManyEose(live, filter, {
- maxWait,
- onevent(event) {
- pending.push(verifyOneAsync(event).then((ok) => ok && onEvent(event)).catch(() => {}));
- },
- onclose: resolve,
- });
+ await askRelays(live, [filter], maxWait, (event) => {
+ pending.push(verifyOneAsync(event).then((ok) => ok && onEvent(event)).catch(() => {}));
});
} catch {} finally { await Promise.all(pending); }
}
@@ -657,7 +707,7 @@ export const PROFILE_RELAYS = HOME_RELAYS;
// Fetch a recipient's profile (name + picture) for a pubkey, newest across relays.
export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) {
let events;
- try { events = await pool.querySync(relays, { kinds: [0], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return null; }
+ try { events = await queryOn(relays, { kinds: [0], authors: [pubkeyHex] }, 5000); } catch { return null; }
const best = events.sort((a, b) => b.created_at - a.created_at)[0];
if (!best) return null;
try {
@@ -679,7 +729,7 @@ export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) {
// 10050), else their NIP-65 read relays (kind 10002), else [] (caller falls back).
export async function fetchInboxRelays(pubkeyHex, relays = PROFILE_RELAYS) {
let events;
- try { events = await pool.querySync(relays, { kinds: [10002, 10050], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return []; }
+ try { events = await queryOn(relays, { kinds: [10002, 10050], authors: [pubkeyHex] }, 5000); } catch { return []; }
const newest = (kind) => events.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0];
const dm = newest(10050);
if (dm) { const r = dm.tags.filter((t) => t[0] === 'relay' && t[1]).map((t) => t[1]); if (r.length) return r; }