@unicitylabs/nostr-js-sdk 0.4.1 → 0.5.0-dev.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,6 +50,35 @@ const DEFAULT_QUERY_TIMEOUT_MS = 5000;
50
50
  const DEFAULT_RECONNECT_INTERVAL_MS = 1000;
51
51
  const DEFAULT_MAX_RECONNECT_INTERVAL_MS = 30000;
52
52
  const DEFAULT_PING_INTERVAL_MS = 30000;
53
+ /**
54
+ * Internal sub_id reserved for the keepalive REQ. Namespaced with a
55
+ * `__nostr-sdk-` prefix so that user code calling
56
+ * {@link NostrClient.subscribe} with an explicit `subscriptionId`
57
+ * cannot collide — a user choosing the literal `"ping"` would
58
+ * otherwise have their subscription forcibly CLOSE/REQ'd every
59
+ * ping interval. The leading `__` is a stable convention for
60
+ * "do not pick this name."
61
+ */
62
+ const PING_SUB_ID = '__nostr-sdk-keepalive__';
63
+ /**
64
+ * Filter id used by the keepalive REQ. We need a filter the relay can
65
+ * resolve immediately (so EOSE comes back fast = relay is alive), but
66
+ * which can NOT match any real event past EOSE (so the live tail stays
67
+ * empty).
68
+ *
69
+ * Scoping by `authors:[selfPubkey]` was the original approach but it
70
+ * matched every event the wallet itself published — including kind-31113
71
+ * token transfers — which the relay then echoed back on the keepalive
72
+ * sub. Some relays dedupe events across overlapping subs, so the
73
+ * wallet's own consumer subscription would not receive its own echo and
74
+ * any flow waiting on that echo would time out.
75
+ *
76
+ * The filter `{ ids: ['00...00'] }` asks the relay for a single event
77
+ * whose id is exactly the all-zero hash. Real Nostr event ids are
78
+ * SHA-256 over a canonical JSON serialization, so the all-zero hash is
79
+ * unreachable in practice. Result: instant EOSE, empty live tail.
80
+ */
81
+ const KEEPALIVE_NEVER_MATCH_ID = '0'.repeat(64);
53
82
  /**
54
83
  * Delay before resubscribing after NIP-42 authentication.
55
84
  * This gives the relay time to process the AUTH response before we send
@@ -89,6 +118,30 @@ class NostrClient {
89
118
  this.maxReconnectIntervalMs = options?.maxReconnectIntervalMs ?? DEFAULT_MAX_RECONNECT_INTERVAL_MS;
90
119
  this.pingIntervalMs = options?.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
91
120
  }
121
+ /**
122
+ * Replace the key manager used for signing and encryption.
123
+ *
124
+ * The connection stays alive — but every operation that consults the
125
+ * key manager from this point on uses the new key, including:
126
+ * - signing future published events,
127
+ * - signing NIP-42 AUTH challenge responses,
128
+ * - the `authors:[selfPubkey]` filter on the keepalive ping REQ
129
+ * (computed each ping interval),
130
+ * - any other code path that calls `getPublicKeyHex()` on the
131
+ * stored manager.
132
+ *
133
+ * Existing in-flight subscriptions are not re-issued or re-keyed.
134
+ * @param keyManager New key manager
135
+ */
136
+ setKeyManager(keyManager) {
137
+ this.keyManager = keyManager;
138
+ }
139
+ /**
140
+ * Get the current key manager.
141
+ */
142
+ getKeyManager() {
143
+ return this.keyManager;
144
+ }
92
145
  /**
93
146
  * Add a connection event listener.
94
147
  * @param listener Listener for connection events
@@ -132,13 +185,6 @@ class NostrClient {
132
185
  }
133
186
  }
134
187
  }
135
- /**
136
- * Get the key manager.
137
- * @returns The key manager instance
138
- */
139
- getKeyManager() {
140
- return this.keyManager;
141
- }
142
188
  /**
143
189
  * Get the current query timeout in milliseconds.
144
190
  * @returns Query timeout in milliseconds
@@ -175,11 +221,41 @@ class NostrClient {
175
221
  return;
176
222
  }
177
223
  return new Promise((resolve, reject) => {
224
+ // The connection-setup timeout has three races to defend
225
+ // against:
226
+ // A) createWebSocket resolves AFTER the timeout fired.
227
+ // B) createWebSocket resolves BEFORE the timeout, but
228
+ // `onopen` fires AFTER the timeout fired.
229
+ // C) createWebSocket resolves and `onopen` fires BEFORE the
230
+ // timeout (the success path).
231
+ // `pendingSocket` lets the timeout proactively close any
232
+ // socket that's already been created but hasn't fired
233
+ // `onopen` yet. The `timedOut` flag covers (A) inside `.then`
234
+ // and (B) inside `socket.onopen`.
235
+ let timedOut = false;
236
+ let pendingSocket = null;
178
237
  const timeoutId = setTimeout(() => {
238
+ timedOut = true;
239
+ if (pendingSocket) {
240
+ try {
241
+ pendingSocket.close(1000, 'Connection setup timed out');
242
+ }
243
+ catch { /* ignore */ }
244
+ }
179
245
  reject(new Error(`Connection to ${url} timed out`));
180
246
  }, CONNECTION_TIMEOUT_MS);
181
247
  (0, WebSocketAdapter_js_1.createWebSocket)(url)
182
248
  .then((socket) => {
249
+ if (timedOut) {
250
+ // Caller already saw the rejection. Discard the late
251
+ // socket so we don't leak it.
252
+ try {
253
+ socket.close(1000, 'Connection setup timed out');
254
+ }
255
+ catch { /* ignore */ }
256
+ return;
257
+ }
258
+ pendingSocket = socket;
183
259
  const relay = {
184
260
  url,
185
261
  socket,
@@ -191,8 +267,28 @@ class NostrClient {
191
267
  lastPongTime: Date.now(),
192
268
  unansweredPings: 0,
193
269
  wasConnected: existingRelay?.wasConnected ?? false,
270
+ // Reset on every new connection: a relay's per-connection
271
+ // sub-slot accounting is fresh, so previously-rejected REQs
272
+ // should be re-issued on the new socket.
273
+ closedSubIds: new Set(),
274
+ eosedSubIds: new Set(),
194
275
  };
195
276
  socket.onopen = () => {
277
+ // The `.then` block already guards against a socket
278
+ // arriving after the connection timeout, but the socket
279
+ // can also be created BEFORE the timeout while
280
+ // `onopen` fires AFTER the timeout has rejected the
281
+ // outer promise. Without this second guard we'd register
282
+ // the relay, start a pingTimer, and resubscribe — orphan
283
+ // background resources the caller can't see or clean up
284
+ // because their connect() call already saw a rejection.
285
+ if (timedOut) {
286
+ try {
287
+ socket.close(1000, 'Connection setup timed out');
288
+ }
289
+ catch { /* ignore */ }
290
+ return;
291
+ }
196
292
  clearTimeout(timeoutId);
197
293
  relay.connected = true;
198
294
  relay.reconnectAttempts = 0; // Reset on successful connection
@@ -233,9 +329,40 @@ class NostrClient {
233
329
  const wasConnected = relay.connected;
234
330
  relay.connected = false;
235
331
  this.stopPingTimer(url);
332
+ // Pre-onopen close: TCP handshake failure or relay
333
+ // immediately closed the WS during the upgrade. Without
334
+ // this, the connectToRelay promise stays pending until
335
+ // CONNECTION_TIMEOUT_MS (30s) expires; surfacing it now
336
+ // lets the caller see the failure promptly and retry.
337
+ if (!wasConnected && !timedOut) {
338
+ timedOut = true;
339
+ clearTimeout(timeoutId);
340
+ reject(new Error(`Connection to ${url} closed during handshake: ${event?.reason || 'no reason'}`));
341
+ }
236
342
  if (wasConnected) {
237
343
  const reason = event?.reason || 'Connection closed';
238
344
  this.emitConnectionEvent('disconnect', url, reason);
345
+ // Re-trigger the all-done check on every active sub.
346
+ // queryWithFirstSeenWins.allRelaysDoneFor only runs
347
+ // from listener callbacks (EOSE / CLOSED via onError);
348
+ // a socket that drops without sending either would
349
+ // otherwise leave the query hanging until
350
+ // queryTimeoutMs even though the disconnected relay no
351
+ // longer counts toward "still pending" relays. Firing
352
+ // a synthetic onError gives every active sub a chance
353
+ // to re-evaluate now that the relay set has shrunk.
354
+ // Include the relay URL so listeners in a multi-relay
355
+ // client can attribute which relay dropped.
356
+ const inflight = Array.from(this.subscriptions.entries());
357
+ for (const [subId, sub] of inflight) {
358
+ try {
359
+ sub.listener.onError?.(subId, `Relay disconnected (${url}): ${reason}`);
360
+ }
361
+ catch {
362
+ // Ignore listener errors — we're notifying
363
+ // best-effort.
364
+ }
365
+ }
239
366
  }
240
367
  if (!this.closed && this.autoReconnect && !relay.reconnecting) {
241
368
  this.scheduleReconnect(url);
@@ -247,7 +374,11 @@ class NostrClient {
247
374
  reject(new Error(`Failed to connect to ${url}: ${error.message || 'Unknown error'}`));
248
375
  }
249
376
  };
250
- this.relays.set(url, relay);
377
+ // Note: we do NOT register the relay in `this.relays` here —
378
+ // only after `onopen` fires successfully. Registering eagerly
379
+ // (before onopen) would leak the relay into the global map
380
+ // even when the connection setup times out and the caller's
381
+ // promise has already rejected.
251
382
  })
252
383
  .catch((error) => {
253
384
  clearTimeout(timeoutId);
@@ -324,16 +455,31 @@ class NostrClient {
324
455
  }
325
456
  return;
326
457
  }
327
- // Send a subscription request as a ping (relays respond with EOSE)
328
- // Use a single fixed subscription ID per relay to avoid accumulating subscriptions
329
- // Note: limit:1 is used because some relays don't respond to limit:0
458
+ // Send a subscription request as a ping (relays respond with EOSE).
459
+ // The filter MUST be tightly scoped to something no real event can
460
+ // match both for the initial query AND the post-EOSE live tail.
461
+ //
462
+ // Earlier iterations used `authors:[self]` reasoning that "the
463
+ // relay would only forward our own future events". That reasoning
464
+ // was wrong: it precisely DOES forward every event the wallet
465
+ // publishes, including kind-31113 token transfers. Some relays
466
+ // dedupe events across overlapping subs, so the wallet's own
467
+ // consumer subscription would miss its echo and any flow waiting
468
+ // on it would time out.
469
+ //
470
+ // {@link KEEPALIVE_NEVER_MATCH_ID} (the all-zero SHA-256 hash) is
471
+ // unreachable in real event-id space, so the relay returns EOSE
472
+ // immediately and the live tail never matches.
330
473
  try {
331
- const pingSubId = `ping`;
332
474
  // First close any existing ping subscription to ensure we don't accumulate
333
- const closeMessage = JSON.stringify(['CLOSE', pingSubId]);
475
+ const closeMessage = JSON.stringify(['CLOSE', PING_SUB_ID]);
334
476
  relay.socket.send(closeMessage);
335
477
  // Then send the new ping request (limit:1 ensures relay sends EOSE)
336
- const pingMessage = JSON.stringify(['REQ', pingSubId, { limit: 1 }]);
478
+ const pingMessage = JSON.stringify([
479
+ 'REQ',
480
+ PING_SUB_ID,
481
+ { ids: [KEEPALIVE_NEVER_MATCH_ID], limit: 1 },
482
+ ]);
337
483
  relay.socket.send(pingMessage);
338
484
  relay.unansweredPings++;
339
485
  }
@@ -368,6 +514,11 @@ class NostrClient {
368
514
  if (!relay?.socket || !relay.connected)
369
515
  return;
370
516
  for (const [subId, info] of this.subscriptions) {
517
+ // Skip subs this relay has previously CLOSED — re-issuing them
518
+ // just triggers the same rejection in a loop. Other healthy
519
+ // relays still resubscribe.
520
+ if (relay.closedSubIds.has(subId))
521
+ continue;
371
522
  const message = JSON.stringify(['REQ', subId, info.filter.toJSON()]);
372
523
  relay.socket.send(message);
373
524
  }
@@ -387,7 +538,7 @@ class NostrClient {
387
538
  /**
388
539
  * Handle a message from a relay.
389
540
  */
390
- handleRelayMessage(_url, message) {
541
+ handleRelayMessage(relayUrl, message) {
391
542
  try {
392
543
  const json = JSON.parse(message);
393
544
  if (!Array.isArray(json) || json.length < 2)
@@ -401,16 +552,16 @@ class NostrClient {
401
552
  this.handleOkMessage(json);
402
553
  break;
403
554
  case 'EOSE':
404
- this.handleEOSEMessage(json);
555
+ this.handleEOSEMessage(relayUrl, json);
405
556
  break;
406
557
  case 'NOTICE':
407
558
  this.handleNoticeMessage(json);
408
559
  break;
409
560
  case 'CLOSED':
410
- this.handleClosedMessage(json);
561
+ this.handleClosedMessage(relayUrl, json);
411
562
  break;
412
563
  case 'AUTH':
413
- this.handleAuthMessage(_url, json);
564
+ this.handleAuthMessage(relayUrl, json);
414
565
  break;
415
566
  }
416
567
  }
@@ -422,7 +573,7 @@ class NostrClient {
422
573
  * Handle EVENT message from relay.
423
574
  */
424
575
  handleEventMessage(json) {
425
- if (json.length < 3)
576
+ if (json.length < 3 || typeof json[1] !== 'string')
426
577
  return;
427
578
  const subscriptionId = json[1];
428
579
  const eventData = json[2];
@@ -460,11 +611,23 @@ class NostrClient {
460
611
  }
461
612
  /**
462
613
  * Handle EOSE (End of Stored Events) message from relay.
614
+ *
615
+ * Records the per-relay EOSE marker (mirroring closedSubIds) so
616
+ * queryWithFirstSeenWins can decide when ALL connected relays have
617
+ * finished — either streamed EOSE or rejected with CLOSED — instead
618
+ * of settling off the first fast relay's EOSE while a slower relay
619
+ * is still about to deliver matching events.
463
620
  */
464
- handleEOSEMessage(json) {
465
- if (json.length < 2)
621
+ handleEOSEMessage(relayUrl, json) {
622
+ if (json.length < 2 || typeof json[1] !== 'string')
466
623
  return;
467
624
  const subscriptionId = json[1];
625
+ if (!this.subscriptions.has(subscriptionId))
626
+ return;
627
+ const relay = this.relays.get(relayUrl);
628
+ if (relay) {
629
+ relay.eosedSubIds.add(subscriptionId);
630
+ }
468
631
  const subscription = this.subscriptions.get(subscriptionId);
469
632
  if (subscription?.listener.onEndOfStoredEvents) {
470
633
  subscription.listener.onEndOfStoredEvents(subscriptionId);
@@ -481,15 +644,64 @@ class NostrClient {
481
644
  }
482
645
  /**
483
646
  * Handle CLOSED message from relay (subscription closed by relay).
647
+ *
648
+ * NIP-01 CLOSED frames are terminal for the named subscription **on
649
+ * the sending relay**. In a multi-relay client the same sub_id may
650
+ * still be alive on a healthy relay, so we must NOT delete the
651
+ * global `this.subscriptions` entry here — that would silently drop
652
+ * EVENT/EOSE frames from the still-healthy relays in
653
+ * `handleEventMessage` (which consults the global map).
654
+ *
655
+ * Instead we record the rejection on the sending relay's
656
+ * `closedSubIds` set so `resubscribeAll` and post-AUTH resubscribe
657
+ * skip it on this relay only. The listener is notified via
658
+ * `onError` so callers (e.g. queryWithFirstSeenWins) can decide to
659
+ * settle and explicitly `unsubscribe()` if they want to give up
660
+ * across all relays.
484
661
  */
485
- handleClosedMessage(json) {
486
- if (json.length < 3)
662
+ handleClosedMessage(relayUrl, json) {
663
+ // NIP-01 makes the message field optional: `["CLOSED", <sub>]` is
664
+ // valid. Dropping such frames was exactly the leak this PR sets out
665
+ // to fix — no closedSubIds marker and no onError notification means
666
+ // queries hang until timeout and resubscribe loops persist.
667
+ if (json.length < 2 || typeof json[1] !== 'string')
487
668
  return;
488
669
  const subscriptionId = json[1];
489
- const message = json[2];
670
+ // Ignore CLOSED for sub_ids we don't know about. A misbehaving or
671
+ // malicious relay could otherwise spam us with arbitrary sub_ids
672
+ // and grow `closedSubIds` unbounded over a long-lived connection,
673
+ // and could pre-emptively block sub_ids we might use later.
674
+ if (!this.subscriptions.has(subscriptionId))
675
+ return;
676
+ const message = typeof json[2] === 'string' ? json[2] : 'no reason provided';
677
+ // NIP-42 transient case: relays that require AUTH typically reject
678
+ // pre-auth REQs with `CLOSED("auth-required:...")` and then send
679
+ // an AUTH challenge. resubscribeAfterAuth re-issues the sub, so
680
+ // this rejection is NOT terminal. If we marked closedSubIds here,
681
+ // queryWithFirstSeenWins.onError would settle the future
682
+ // prematurely (single-relay → allRelaysDoneFor=true), unsubscribe
683
+ // the sub, and the post-AUTH retry would find nothing to retry.
684
+ // Listener still gets onError so callers see the reason; we just
685
+ // don't poison the per-relay state with a transient marker.
686
+ //
687
+ // We accept three on-the-wire shapes: `auth-required:...`
688
+ // (NIP-42 standard with reason), `auth-required ...` (whitespace
689
+ // separator), and bare `auth-required` (no suffix at all — some
690
+ // relays / tests).
691
+ const isAuthRequired = message === 'auth-required'
692
+ || message.startsWith('auth-required:')
693
+ || message.startsWith('auth-required ');
694
+ const relay = this.relays.get(relayUrl);
695
+ if (relay && !isAuthRequired) {
696
+ relay.closedSubIds.add(subscriptionId);
697
+ }
490
698
  const subscription = this.subscriptions.get(subscriptionId);
491
699
  if (subscription?.listener.onError) {
492
- subscription.listener.onError(subscriptionId, `Subscription closed: ${message}`);
700
+ // Pass the relay's reason through verbatim so callers can
701
+ // pattern-match on standard prefixes (`auth-required:`,
702
+ // `rate-limited:`, `blocked:`, etc.) without parsing through
703
+ // a wrapper string.
704
+ subscription.listener.onError(subscriptionId, message);
493
705
  }
494
706
  }
495
707
  /**
@@ -514,8 +726,27 @@ class NostrClient {
514
726
  // Send AUTH response
515
727
  const message = JSON.stringify(['AUTH', authEvent.toJSON()]);
516
728
  relay.socket.send(message);
517
- // Re-send subscriptions after auth (relay may have ignored pre-auth requests)
729
+ // Re-send subscriptions after auth (relay may have ignored pre-auth
730
+ // requests). Two separate per-relay markers, two separate decisions:
731
+ //
732
+ // - `closedSubIds`: do NOT clear. handleClosedMessage already
733
+ // skips the auth-required transient case, so anything in this
734
+ // set is a TERMINAL rejection (rate-limited, blocked, etc.)
735
+ // that AUTH does not relax. The resubscribeAll guard then
736
+ // correctly skips terminal-rejected subs on this relay. They
737
+ // will be retried on the next reconnect, when onopen creates a
738
+ // fresh RelayConnection with empty markers.
739
+ //
740
+ // - `eosedSubIds`: clear. A relay may have EOSE'd a pre-auth sub
741
+ // with zero events (filter unsatisfiable without auth context);
742
+ // post-auth the same filter might match. We must re-arm the
743
+ // local "still waiting" state so any in-flight
744
+ // queryWithFirstSeenWins doesn't see this relay as already-done
745
+ // from a stale marker.
518
746
  setTimeout(() => {
747
+ const r = this.relays.get(relayUrl);
748
+ if (r)
749
+ r.eosedSubIds.clear();
519
750
  this.resubscribeAll(relayUrl);
520
751
  }, AUTH_RESUBSCRIBE_DELAY_MS);
521
752
  }
@@ -535,24 +766,40 @@ class NostrClient {
535
766
  item.reject(new Error('Client disconnected'));
536
767
  }
537
768
  this.eventQueue = [];
538
- // Close all relay connections and clean up timers
769
+ // Close all relay connections and clean up timers. Mark every
770
+ // relay disconnected synchronously BEFORE we notify subscriptions
771
+ // below, so any listener that consults `allRelaysDoneFor` sees
772
+ // zero connected relays and settles immediately.
539
773
  for (const [url, relay] of this.relays) {
540
- // Stop ping timer
774
+ relay.connected = false;
541
775
  if (relay.pingTimer) {
542
776
  clearInterval(relay.pingTimer);
543
777
  relay.pingTimer = null;
544
778
  }
545
- // Stop reconnect timer
546
779
  if (relay.reconnectTimer) {
547
780
  clearTimeout(relay.reconnectTimer);
548
781
  relay.reconnectTimer = null;
549
782
  }
550
- // Close socket
551
783
  if (relay.socket && relay.socket.readyState !== WebSocketAdapter_js_1.CLOSED) {
552
784
  relay.socket.close(1000, 'Client disconnected');
553
785
  }
554
786
  this.emitConnectionEvent('disconnect', url, 'Client disconnected');
555
787
  }
788
+ // Notify in-flight subscriptions that we're shutting down.
789
+ // queryWithFirstSeenWins.onError re-checks allRelaysDoneFor (now
790
+ // 0 connected → trivially true) and settles immediately, sparing
791
+ // callers the full queryTimeoutMs wait. Snapshot keys first
792
+ // because the listener may call unsubscribe(), which mutates
793
+ // this.subscriptions while we iterate.
794
+ const inflightSubs = Array.from(this.subscriptions.entries());
795
+ for (const [subId, sub] of inflightSubs) {
796
+ try {
797
+ sub.listener.onError?.(subId, 'Client disconnected');
798
+ }
799
+ catch {
800
+ // Ignore listener errors — we're tearing down anyway.
801
+ }
802
+ }
556
803
  this.relays.clear();
557
804
  this.subscriptions.clear();
558
805
  }
@@ -754,7 +1001,22 @@ class NostrClient {
754
1001
  filter = filterOrSubId;
755
1002
  listener = listenerOrFilter;
756
1003
  }
1004
+ // Reserved prefix for SDK-internal sub_ids (currently just the
1005
+ // keepalive `PING_SUB_ID`). Reject explicit caller use so the
1006
+ // keepalive timer's CLOSE/REQ cycle can't stomp on user state.
1007
+ if (subscriptionId.startsWith('__nostr-sdk-')) {
1008
+ throw new Error(`Subscription ID "${subscriptionId}" uses the reserved "__nostr-sdk-" prefix — pick a different id.`);
1009
+ }
757
1010
  this.subscriptions.set(subscriptionId, { filter, listener });
1011
+ // Wipe any stale per-relay EOSE/CLOSED markers for this sub_id
1012
+ // before issuing the REQ — otherwise a fresh subscribe with a
1013
+ // sub_id that was previously CLOSED (or was just freshly
1014
+ // EOSE'd) would be skipped or treated as "already done" on
1015
+ // those relays.
1016
+ for (const [, relay] of this.relays) {
1017
+ relay.closedSubIds.delete(subscriptionId);
1018
+ relay.eosedSubIds.delete(subscriptionId);
1019
+ }
758
1020
  // Send subscription request to all connected relays
759
1021
  const message = JSON.stringify(['REQ', subscriptionId, filter.toJSON()]);
760
1022
  for (const [, relay] of this.relays) {
@@ -772,12 +1034,19 @@ class NostrClient {
772
1034
  if (!this.subscriptions.has(subscriptionId))
773
1035
  return;
774
1036
  this.subscriptions.delete(subscriptionId);
775
- // Send CLOSE to all connected relays
1037
+ // Send CLOSE to all connected relays — except those that already
1038
+ // CLOSED the sub themselves (no point telling the relay something
1039
+ // it told us).
776
1040
  const message = JSON.stringify(['CLOSE', subscriptionId]);
777
1041
  for (const [, relay] of this.relays) {
778
- if (relay.connected && relay.socket?.readyState === WebSocketAdapter_js_1.OPEN) {
1042
+ if (relay.connected && relay.socket?.readyState === WebSocketAdapter_js_1.OPEN
1043
+ && !relay.closedSubIds.has(subscriptionId)) {
779
1044
  relay.socket.send(message);
780
1045
  }
1046
+ // Drop both per-relay markers now that the sub is gone from
1047
+ // the global map.
1048
+ relay.closedSubIds.delete(subscriptionId);
1049
+ relay.eosedSubIds.delete(subscriptionId);
781
1050
  }
782
1051
  }
783
1052
  /**
@@ -803,12 +1072,45 @@ class NostrClient {
803
1072
  queryWithFirstSeenWins(filter, extractResult) {
804
1073
  return new Promise((resolve) => {
805
1074
  let subscriptionId = '';
806
- const timeoutId = setTimeout(() => {
807
- if (subscriptionId)
808
- this.unsubscribe(subscriptionId);
809
- resolve(null);
810
- }, this.queryTimeoutMs);
1075
+ let settled = false;
1076
+ // Declared as `let` and initialized lazily so `finishWith` can be
1077
+ // invoked before the setTimeout call below without hitting the
1078
+ // TDZ on `clearTimeout(timeoutId)`. (The same comment on the
1079
+ // listener anticipates synchronous-callback hypothetical paths.)
1080
+ let timeoutId;
1081
+ // Accept an explicit `id` so callers from inside the listener can
1082
+ // pass the sub_id the relay echoed back. This guards against any
1083
+ // future change to subscribe() that would invoke listener
1084
+ // callbacks before its return value is bound to `subscriptionId`
1085
+ // — the closure-captured value would still be `''` and we'd skip
1086
+ // the CLOSE frame, leaking the slot on the relay.
1087
+ const finishWith = (result, id) => {
1088
+ if (settled)
1089
+ return;
1090
+ settled = true;
1091
+ if (timeoutId !== undefined)
1092
+ clearTimeout(timeoutId);
1093
+ const subId = id || subscriptionId;
1094
+ if (subId)
1095
+ this.unsubscribe(subId);
1096
+ resolve(result);
1097
+ };
1098
+ timeoutId = setTimeout(() => finishWith(null), this.queryTimeoutMs);
811
1099
  const authors = new Map();
1100
+ const allRelaysDone = (id) => this.allRelaysDoneFor(id);
1101
+ const pickWinner = () => {
1102
+ let winnerEntry = null;
1103
+ let winnerPubkey = '';
1104
+ for (const [pubkey, entry] of authors) {
1105
+ if (!winnerEntry
1106
+ || entry.firstSeen < winnerEntry.firstSeen
1107
+ || (entry.firstSeen === winnerEntry.firstSeen && pubkey < winnerPubkey)) {
1108
+ winnerEntry = entry;
1109
+ winnerPubkey = pubkey;
1110
+ }
1111
+ }
1112
+ return winnerEntry ? extractResult(winnerEntry.latestEvent) : null;
1113
+ };
812
1114
  subscriptionId = this.subscribe(filter, {
813
1115
  onEvent: (event) => {
814
1116
  // Verify signature to prevent relay injection of forged events (#4)
@@ -827,24 +1129,55 @@ class NostrClient {
827
1129
  }
828
1130
  }
829
1131
  },
830
- onEndOfStoredEvents: () => {
831
- clearTimeout(timeoutId);
832
- this.unsubscribe(subscriptionId);
833
- let winnerEntry = null;
834
- let winnerPubkey = '';
835
- for (const [pubkey, entry] of authors) {
836
- if (!winnerEntry
837
- || entry.firstSeen < winnerEntry.firstSeen
838
- || (entry.firstSeen === winnerEntry.firstSeen && pubkey < winnerPubkey)) {
839
- winnerEntry = entry;
840
- winnerPubkey = pubkey;
841
- }
1132
+ // EOSE means *this relay* has finished delivering stored
1133
+ // events. In a multi-relay client we must not settle yet — a
1134
+ // slower relay may still be about to deliver matching events.
1135
+ // Settle only when every connected relay has either EOSE'd
1136
+ // OR CLOSED'd this sub. (Single-relay clients are unaffected:
1137
+ // allDone is trivially true with one relay.)
1138
+ onEndOfStoredEvents: (id) => {
1139
+ if (allRelaysDone(id)) {
1140
+ finishWith(pickWinner(), id);
1141
+ }
1142
+ },
1143
+ // Subscription error from the SDK — fires from three paths
1144
+ // that all need the same "is it time to settle?" check:
1145
+ // 1. Relay sent CLOSED for this sub. In a multi-relay
1146
+ // client the same sub_id may still be alive on a
1147
+ // healthy relay; settling on the first CLOSED would
1148
+ // prematurely abort a query other relays could
1149
+ // satisfy. handleClosedMessage records the rejection
1150
+ // on the sending relay's closedSubIds before invoking
1151
+ // us, so we can decide via allRelaysDoneFor.
1152
+ // 2. Relay disconnected mid-query (socket.onclose →
1153
+ // synthetic onError). The relay no longer counts as
1154
+ // connected, so allRelaysDoneFor excludes it.
1155
+ // 3. Client disconnected (disconnect() → synthetic
1156
+ // onError). All relays are torn down, allRelaysDoneFor
1157
+ // sees zero connected and settles.
1158
+ onError: (id, message) => {
1159
+ console.warn(`Subscription error on ${id}: ${message}`);
1160
+ if (allRelaysDone(id)) {
1161
+ finishWith(pickWinner(), id);
842
1162
  }
843
- resolve(winnerEntry ? extractResult(winnerEntry.latestEvent) : null);
1163
+ // else: keep waiting for EOSE / CLOSED from remaining
1164
+ // relays or the overall query timeout.
844
1165
  },
845
1166
  });
846
1167
  });
847
1168
  }
1169
+ /**
1170
+ * True if every currently-connected relay has finished delivering
1171
+ * for the given sub_id (either EOSE'd or CLOSED'd it). Used by
1172
+ * queryWithFirstSeenWins to coordinate multi-relay settlement.
1173
+ */
1174
+ allRelaysDoneFor(subscriptionId) {
1175
+ const connected = Array.from(this.relays.values()).filter((r) => r.connected);
1176
+ // No connected relays at all → nothing to wait for; settle.
1177
+ if (connected.length === 0)
1178
+ return true;
1179
+ return connected.every((r) => r.eosedSubIds.has(subscriptionId) || r.closedSubIds.has(subscriptionId));
1180
+ }
848
1181
  /**
849
1182
  * Query for a public key by nametag.
850
1183
  * Uses first-seen-wins anti-hijacking resolution.