aboutsummaryrefslogtreecommitdiffstats
path: root/src/nostr.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/nostr.js')
-rw-r--r--src/nostr.js149
1 files changed, 126 insertions, 23 deletions
diff --git a/src/nostr.js b/src/nostr.js
index c77fc27..731c076 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -186,7 +186,12 @@ pool.ensureRelay = async (url, params) => {
relay._coinosRL = true;
const notice = relay.onnotice;
relay.onnotice = (msg) => {
- if (RATE_LIMITED.test(String(msg))) markRelayRateLimited(relay.url, String(msg));
+ // Primal's "too many concurrent REQs" is advice, not a refusal: every
+ // REQ past its twenty is answered all the same (measured: 60 at once,
+ // 60 EOSEs, 12 notices). Cooling it off for 30 s at every boot — when
+ // the relay lists, profiles and the feed all ask at once — only lost
+ // us its answers. A CLOSED with that reason still counts (below).
+ if (RATE_LIMITED.test(String(msg)) && !/concurrent/i.test(String(msg))) markRelayRateLimited(relay.url, String(msg));
try { notice?.call(relay, msg); } catch {}
};
const subscribe = relay.subscribe.bind(relay);
@@ -284,6 +289,54 @@ export function resetRelay(url) {
try { r.close(); } catch {}
pool.relays.delete(n);
}
+// Back from a freeze (a phone's backgrounded tab: timers stopped, the
+// network's idea of every socket long gone), the pool's sockets still read
+// OPEN, and anything sent on them vanishes. Left to itself the library
+// notices one in up to 29 s (its ping) + 20 s (the ping's timeout), then
+// waits 10 s more before redialing — a minute of a feed that won't catch up
+// ("no new notes since 11:35?", Adam, 2026-11-01). So on resume every open
+// socket is asked one tiny REQ; one that answers within the deadline is
+// fine, one that doesn't is closed and redialed now, its subscriptions
+// refired by the library with `since` set to the last event each saw. A
+// relay already waiting out a reconnect backoff is dialed now too.
+const WAKE_PING_MS = 2500;
+const DUMMY_ID = 'a'.repeat(64);
+function pingRelay(r, ms) {
+ return new Promise((resolve) => {
+ let done = false;
+ const fin = (ok) => { if (done) return; done = true; clearTimeout(t); resolve(ok); };
+ const t = setTimeout(() => fin(false), ms);
+ try {
+ const sub = r.subscribe([{ ids: [DUMMY_ID], limit: 0 }], {
+ label: '<wake-ping>',
+ oneose: () => { fin(true); try { sub.close(); } catch {} },
+ onclose: () => fin(true), // a relay refusing the REQ is a relay that answers
+ eoseTimeout: ms + 1000, // ours fires first: the library's would count as an EOSE
+ });
+ } catch { fin(false); }
+ });
+}
+function redialRelay(r, why) {
+ console.warn('nostr: ' + r.url + ' ' + why + ' — redialing');
+ // the dead socket's close would reach the library long after (a closing
+ // handshake nobody answers), and undo the new connection: detached first
+ const ws = r.ws;
+ try { if (ws) { ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null; ws.close(); } } catch {}
+ try { r.handleHardClose(why); } catch {} // the library's own path: subscriptions kept, a reconnect queued
+ if (r.reconnectTimeoutHandle) { clearTimeout(r.reconnectTimeoutHandle); r.reconnectTimeoutHandle = undefined; }
+ r.connect().catch(() => {}); // now, not after the backoff
+}
+export async function wakeRelays(ms = WAKE_PING_MS) {
+ const checks = [];
+ for (const r of pool.relays.values()) {
+ if (r.connected && r.ws && r.ws.readyState === 1) checks.push(pingRelay(r, ms).then((ok) => { if (!ok) redialRelay(r, 'silent after resume'); }));
+ else if (!r.connectionPromise && r.reconnectTimeoutHandle) {
+ clearTimeout(r.reconnectTimeoutHandle); r.reconnectTimeoutHandle = undefined;
+ r.connect().catch(() => {});
+ }
+ }
+ await Promise.all(checks);
+}
// the relays a subscription on this list actually dials (sick ones skipped)
export const liveRelayList = (list) => liveRelays(list);
// ...and the ones a one-shot read goes to (rate-limited ones skipped too)
@@ -354,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 []; }
@@ -464,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 []; }
@@ -487,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); }
}
@@ -604,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 {
@@ -626,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; }