@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.
@@ -14,6 +14,35 @@ const DEFAULT_QUERY_TIMEOUT_MS = 5000;
14
14
  const DEFAULT_RECONNECT_INTERVAL_MS = 1000;
15
15
  const DEFAULT_MAX_RECONNECT_INTERVAL_MS = 30000;
16
16
  const DEFAULT_PING_INTERVAL_MS = 30000;
17
+ /**
18
+ * Internal sub_id reserved for the keepalive REQ. Namespaced with a
19
+ * `__nostr-sdk-` prefix so that user code calling
20
+ * {@link NostrClient.subscribe} with an explicit `subscriptionId`
21
+ * cannot collide — a user choosing the literal `"ping"` would
22
+ * otherwise have their subscription forcibly CLOSE/REQ'd every
23
+ * ping interval. The leading `__` is a stable convention for
24
+ * "do not pick this name."
25
+ */
26
+ const PING_SUB_ID = '__nostr-sdk-keepalive__';
27
+ /**
28
+ * Filter id used by the keepalive REQ. We need a filter the relay can
29
+ * resolve immediately (so EOSE comes back fast = relay is alive), but
30
+ * which can NOT match any real event past EOSE (so the live tail stays
31
+ * empty).
32
+ *
33
+ * Scoping by `authors:[selfPubkey]` was the original approach but it
34
+ * matched every event the wallet itself published — including kind-31113
35
+ * token transfers — which the relay then echoed back on the keepalive
36
+ * sub. Some relays dedupe events across overlapping subs, so the
37
+ * wallet's own consumer subscription would not receive its own echo and
38
+ * any flow waiting on that echo would time out.
39
+ *
40
+ * The filter `{ ids: ['00...00'] }` asks the relay for a single event
41
+ * whose id is exactly the all-zero hash. Real Nostr event ids are
42
+ * SHA-256 over a canonical JSON serialization, so the all-zero hash is
43
+ * unreachable in practice. Result: instant EOSE, empty live tail.
44
+ */
45
+ const KEEPALIVE_NEVER_MATCH_ID = '0'.repeat(64);
17
46
  /**
18
47
  * Delay before resubscribing after NIP-42 authentication.
19
48
  * This gives the relay time to process the AUTH response before we send
@@ -53,6 +82,30 @@ export class NostrClient {
53
82
  this.maxReconnectIntervalMs = options?.maxReconnectIntervalMs ?? DEFAULT_MAX_RECONNECT_INTERVAL_MS;
54
83
  this.pingIntervalMs = options?.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
55
84
  }
85
+ /**
86
+ * Replace the key manager used for signing and encryption.
87
+ *
88
+ * The connection stays alive — but every operation that consults the
89
+ * key manager from this point on uses the new key, including:
90
+ * - signing future published events,
91
+ * - signing NIP-42 AUTH challenge responses,
92
+ * - the `authors:[selfPubkey]` filter on the keepalive ping REQ
93
+ * (computed each ping interval),
94
+ * - any other code path that calls `getPublicKeyHex()` on the
95
+ * stored manager.
96
+ *
97
+ * Existing in-flight subscriptions are not re-issued or re-keyed.
98
+ * @param keyManager New key manager
99
+ */
100
+ setKeyManager(keyManager) {
101
+ this.keyManager = keyManager;
102
+ }
103
+ /**
104
+ * Get the current key manager.
105
+ */
106
+ getKeyManager() {
107
+ return this.keyManager;
108
+ }
56
109
  /**
57
110
  * Add a connection event listener.
58
111
  * @param listener Listener for connection events
@@ -96,13 +149,6 @@ export class NostrClient {
96
149
  }
97
150
  }
98
151
  }
99
- /**
100
- * Get the key manager.
101
- * @returns The key manager instance
102
- */
103
- getKeyManager() {
104
- return this.keyManager;
105
- }
106
152
  /**
107
153
  * Get the current query timeout in milliseconds.
108
154
  * @returns Query timeout in milliseconds
@@ -139,11 +185,41 @@ export class NostrClient {
139
185
  return;
140
186
  }
141
187
  return new Promise((resolve, reject) => {
188
+ // The connection-setup timeout has three races to defend
189
+ // against:
190
+ // A) createWebSocket resolves AFTER the timeout fired.
191
+ // B) createWebSocket resolves BEFORE the timeout, but
192
+ // `onopen` fires AFTER the timeout fired.
193
+ // C) createWebSocket resolves and `onopen` fires BEFORE the
194
+ // timeout (the success path).
195
+ // `pendingSocket` lets the timeout proactively close any
196
+ // socket that's already been created but hasn't fired
197
+ // `onopen` yet. The `timedOut` flag covers (A) inside `.then`
198
+ // and (B) inside `socket.onopen`.
199
+ let timedOut = false;
200
+ let pendingSocket = null;
142
201
  const timeoutId = setTimeout(() => {
202
+ timedOut = true;
203
+ if (pendingSocket) {
204
+ try {
205
+ pendingSocket.close(1000, 'Connection setup timed out');
206
+ }
207
+ catch { /* ignore */ }
208
+ }
143
209
  reject(new Error(`Connection to ${url} timed out`));
144
210
  }, CONNECTION_TIMEOUT_MS);
145
211
  createWebSocket(url)
146
212
  .then((socket) => {
213
+ if (timedOut) {
214
+ // Caller already saw the rejection. Discard the late
215
+ // socket so we don't leak it.
216
+ try {
217
+ socket.close(1000, 'Connection setup timed out');
218
+ }
219
+ catch { /* ignore */ }
220
+ return;
221
+ }
222
+ pendingSocket = socket;
147
223
  const relay = {
148
224
  url,
149
225
  socket,
@@ -155,8 +231,28 @@ export class NostrClient {
155
231
  lastPongTime: Date.now(),
156
232
  unansweredPings: 0,
157
233
  wasConnected: existingRelay?.wasConnected ?? false,
234
+ // Reset on every new connection: a relay's per-connection
235
+ // sub-slot accounting is fresh, so previously-rejected REQs
236
+ // should be re-issued on the new socket.
237
+ closedSubIds: new Set(),
238
+ eosedSubIds: new Set(),
158
239
  };
159
240
  socket.onopen = () => {
241
+ // The `.then` block already guards against a socket
242
+ // arriving after the connection timeout, but the socket
243
+ // can also be created BEFORE the timeout while
244
+ // `onopen` fires AFTER the timeout has rejected the
245
+ // outer promise. Without this second guard we'd register
246
+ // the relay, start a pingTimer, and resubscribe — orphan
247
+ // background resources the caller can't see or clean up
248
+ // because their connect() call already saw a rejection.
249
+ if (timedOut) {
250
+ try {
251
+ socket.close(1000, 'Connection setup timed out');
252
+ }
253
+ catch { /* ignore */ }
254
+ return;
255
+ }
160
256
  clearTimeout(timeoutId);
161
257
  relay.connected = true;
162
258
  relay.reconnectAttempts = 0; // Reset on successful connection
@@ -197,9 +293,40 @@ export class NostrClient {
197
293
  const wasConnected = relay.connected;
198
294
  relay.connected = false;
199
295
  this.stopPingTimer(url);
296
+ // Pre-onopen close: TCP handshake failure or relay
297
+ // immediately closed the WS during the upgrade. Without
298
+ // this, the connectToRelay promise stays pending until
299
+ // CONNECTION_TIMEOUT_MS (30s) expires; surfacing it now
300
+ // lets the caller see the failure promptly and retry.
301
+ if (!wasConnected && !timedOut) {
302
+ timedOut = true;
303
+ clearTimeout(timeoutId);
304
+ reject(new Error(`Connection to ${url} closed during handshake: ${event?.reason || 'no reason'}`));
305
+ }
200
306
  if (wasConnected) {
201
307
  const reason = event?.reason || 'Connection closed';
202
308
  this.emitConnectionEvent('disconnect', url, reason);
309
+ // Re-trigger the all-done check on every active sub.
310
+ // queryWithFirstSeenWins.allRelaysDoneFor only runs
311
+ // from listener callbacks (EOSE / CLOSED via onError);
312
+ // a socket that drops without sending either would
313
+ // otherwise leave the query hanging until
314
+ // queryTimeoutMs even though the disconnected relay no
315
+ // longer counts toward "still pending" relays. Firing
316
+ // a synthetic onError gives every active sub a chance
317
+ // to re-evaluate now that the relay set has shrunk.
318
+ // Include the relay URL so listeners in a multi-relay
319
+ // client can attribute which relay dropped.
320
+ const inflight = Array.from(this.subscriptions.entries());
321
+ for (const [subId, sub] of inflight) {
322
+ try {
323
+ sub.listener.onError?.(subId, `Relay disconnected (${url}): ${reason}`);
324
+ }
325
+ catch {
326
+ // Ignore listener errors — we're notifying
327
+ // best-effort.
328
+ }
329
+ }
203
330
  }
204
331
  if (!this.closed && this.autoReconnect && !relay.reconnecting) {
205
332
  this.scheduleReconnect(url);
@@ -211,7 +338,11 @@ export class NostrClient {
211
338
  reject(new Error(`Failed to connect to ${url}: ${error.message || 'Unknown error'}`));
212
339
  }
213
340
  };
214
- this.relays.set(url, relay);
341
+ // Note: we do NOT register the relay in `this.relays` here —
342
+ // only after `onopen` fires successfully. Registering eagerly
343
+ // (before onopen) would leak the relay into the global map
344
+ // even when the connection setup times out and the caller's
345
+ // promise has already rejected.
215
346
  })
216
347
  .catch((error) => {
217
348
  clearTimeout(timeoutId);
@@ -288,16 +419,31 @@ export class NostrClient {
288
419
  }
289
420
  return;
290
421
  }
291
- // Send a subscription request as a ping (relays respond with EOSE)
292
- // Use a single fixed subscription ID per relay to avoid accumulating subscriptions
293
- // Note: limit:1 is used because some relays don't respond to limit:0
422
+ // Send a subscription request as a ping (relays respond with EOSE).
423
+ // The filter MUST be tightly scoped to something no real event can
424
+ // match both for the initial query AND the post-EOSE live tail.
425
+ //
426
+ // Earlier iterations used `authors:[self]` reasoning that "the
427
+ // relay would only forward our own future events". That reasoning
428
+ // was wrong: it precisely DOES forward every event the wallet
429
+ // publishes, including kind-31113 token transfers. Some relays
430
+ // dedupe events across overlapping subs, so the wallet's own
431
+ // consumer subscription would miss its echo and any flow waiting
432
+ // on it would time out.
433
+ //
434
+ // {@link KEEPALIVE_NEVER_MATCH_ID} (the all-zero SHA-256 hash) is
435
+ // unreachable in real event-id space, so the relay returns EOSE
436
+ // immediately and the live tail never matches.
294
437
  try {
295
- const pingSubId = `ping`;
296
438
  // First close any existing ping subscription to ensure we don't accumulate
297
- const closeMessage = JSON.stringify(['CLOSE', pingSubId]);
439
+ const closeMessage = JSON.stringify(['CLOSE', PING_SUB_ID]);
298
440
  relay.socket.send(closeMessage);
299
441
  // Then send the new ping request (limit:1 ensures relay sends EOSE)
300
- const pingMessage = JSON.stringify(['REQ', pingSubId, { limit: 1 }]);
442
+ const pingMessage = JSON.stringify([
443
+ 'REQ',
444
+ PING_SUB_ID,
445
+ { ids: [KEEPALIVE_NEVER_MATCH_ID], limit: 1 },
446
+ ]);
301
447
  relay.socket.send(pingMessage);
302
448
  relay.unansweredPings++;
303
449
  }
@@ -332,6 +478,11 @@ export class NostrClient {
332
478
  if (!relay?.socket || !relay.connected)
333
479
  return;
334
480
  for (const [subId, info] of this.subscriptions) {
481
+ // Skip subs this relay has previously CLOSED — re-issuing them
482
+ // just triggers the same rejection in a loop. Other healthy
483
+ // relays still resubscribe.
484
+ if (relay.closedSubIds.has(subId))
485
+ continue;
335
486
  const message = JSON.stringify(['REQ', subId, info.filter.toJSON()]);
336
487
  relay.socket.send(message);
337
488
  }
@@ -351,7 +502,7 @@ export class NostrClient {
351
502
  /**
352
503
  * Handle a message from a relay.
353
504
  */
354
- handleRelayMessage(_url, message) {
505
+ handleRelayMessage(relayUrl, message) {
355
506
  try {
356
507
  const json = JSON.parse(message);
357
508
  if (!Array.isArray(json) || json.length < 2)
@@ -365,16 +516,16 @@ export class NostrClient {
365
516
  this.handleOkMessage(json);
366
517
  break;
367
518
  case 'EOSE':
368
- this.handleEOSEMessage(json);
519
+ this.handleEOSEMessage(relayUrl, json);
369
520
  break;
370
521
  case 'NOTICE':
371
522
  this.handleNoticeMessage(json);
372
523
  break;
373
524
  case 'CLOSED':
374
- this.handleClosedMessage(json);
525
+ this.handleClosedMessage(relayUrl, json);
375
526
  break;
376
527
  case 'AUTH':
377
- this.handleAuthMessage(_url, json);
528
+ this.handleAuthMessage(relayUrl, json);
378
529
  break;
379
530
  }
380
531
  }
@@ -386,7 +537,7 @@ export class NostrClient {
386
537
  * Handle EVENT message from relay.
387
538
  */
388
539
  handleEventMessage(json) {
389
- if (json.length < 3)
540
+ if (json.length < 3 || typeof json[1] !== 'string')
390
541
  return;
391
542
  const subscriptionId = json[1];
392
543
  const eventData = json[2];
@@ -424,11 +575,23 @@ export class NostrClient {
424
575
  }
425
576
  /**
426
577
  * Handle EOSE (End of Stored Events) message from relay.
578
+ *
579
+ * Records the per-relay EOSE marker (mirroring closedSubIds) so
580
+ * queryWithFirstSeenWins can decide when ALL connected relays have
581
+ * finished — either streamed EOSE or rejected with CLOSED — instead
582
+ * of settling off the first fast relay's EOSE while a slower relay
583
+ * is still about to deliver matching events.
427
584
  */
428
- handleEOSEMessage(json) {
429
- if (json.length < 2)
585
+ handleEOSEMessage(relayUrl, json) {
586
+ if (json.length < 2 || typeof json[1] !== 'string')
430
587
  return;
431
588
  const subscriptionId = json[1];
589
+ if (!this.subscriptions.has(subscriptionId))
590
+ return;
591
+ const relay = this.relays.get(relayUrl);
592
+ if (relay) {
593
+ relay.eosedSubIds.add(subscriptionId);
594
+ }
432
595
  const subscription = this.subscriptions.get(subscriptionId);
433
596
  if (subscription?.listener.onEndOfStoredEvents) {
434
597
  subscription.listener.onEndOfStoredEvents(subscriptionId);
@@ -445,15 +608,64 @@ export class NostrClient {
445
608
  }
446
609
  /**
447
610
  * Handle CLOSED message from relay (subscription closed by relay).
611
+ *
612
+ * NIP-01 CLOSED frames are terminal for the named subscription **on
613
+ * the sending relay**. In a multi-relay client the same sub_id may
614
+ * still be alive on a healthy relay, so we must NOT delete the
615
+ * global `this.subscriptions` entry here — that would silently drop
616
+ * EVENT/EOSE frames from the still-healthy relays in
617
+ * `handleEventMessage` (which consults the global map).
618
+ *
619
+ * Instead we record the rejection on the sending relay's
620
+ * `closedSubIds` set so `resubscribeAll` and post-AUTH resubscribe
621
+ * skip it on this relay only. The listener is notified via
622
+ * `onError` so callers (e.g. queryWithFirstSeenWins) can decide to
623
+ * settle and explicitly `unsubscribe()` if they want to give up
624
+ * across all relays.
448
625
  */
449
- handleClosedMessage(json) {
450
- if (json.length < 3)
626
+ handleClosedMessage(relayUrl, json) {
627
+ // NIP-01 makes the message field optional: `["CLOSED", <sub>]` is
628
+ // valid. Dropping such frames was exactly the leak this PR sets out
629
+ // to fix — no closedSubIds marker and no onError notification means
630
+ // queries hang until timeout and resubscribe loops persist.
631
+ if (json.length < 2 || typeof json[1] !== 'string')
451
632
  return;
452
633
  const subscriptionId = json[1];
453
- const message = json[2];
634
+ // Ignore CLOSED for sub_ids we don't know about. A misbehaving or
635
+ // malicious relay could otherwise spam us with arbitrary sub_ids
636
+ // and grow `closedSubIds` unbounded over a long-lived connection,
637
+ // and could pre-emptively block sub_ids we might use later.
638
+ if (!this.subscriptions.has(subscriptionId))
639
+ return;
640
+ const message = typeof json[2] === 'string' ? json[2] : 'no reason provided';
641
+ // NIP-42 transient case: relays that require AUTH typically reject
642
+ // pre-auth REQs with `CLOSED("auth-required:...")` and then send
643
+ // an AUTH challenge. resubscribeAfterAuth re-issues the sub, so
644
+ // this rejection is NOT terminal. If we marked closedSubIds here,
645
+ // queryWithFirstSeenWins.onError would settle the future
646
+ // prematurely (single-relay → allRelaysDoneFor=true), unsubscribe
647
+ // the sub, and the post-AUTH retry would find nothing to retry.
648
+ // Listener still gets onError so callers see the reason; we just
649
+ // don't poison the per-relay state with a transient marker.
650
+ //
651
+ // We accept three on-the-wire shapes: `auth-required:...`
652
+ // (NIP-42 standard with reason), `auth-required ...` (whitespace
653
+ // separator), and bare `auth-required` (no suffix at all — some
654
+ // relays / tests).
655
+ const isAuthRequired = message === 'auth-required'
656
+ || message.startsWith('auth-required:')
657
+ || message.startsWith('auth-required ');
658
+ const relay = this.relays.get(relayUrl);
659
+ if (relay && !isAuthRequired) {
660
+ relay.closedSubIds.add(subscriptionId);
661
+ }
454
662
  const subscription = this.subscriptions.get(subscriptionId);
455
663
  if (subscription?.listener.onError) {
456
- subscription.listener.onError(subscriptionId, `Subscription closed: ${message}`);
664
+ // Pass the relay's reason through verbatim so callers can
665
+ // pattern-match on standard prefixes (`auth-required:`,
666
+ // `rate-limited:`, `blocked:`, etc.) without parsing through
667
+ // a wrapper string.
668
+ subscription.listener.onError(subscriptionId, message);
457
669
  }
458
670
  }
459
671
  /**
@@ -478,8 +690,27 @@ export class NostrClient {
478
690
  // Send AUTH response
479
691
  const message = JSON.stringify(['AUTH', authEvent.toJSON()]);
480
692
  relay.socket.send(message);
481
- // Re-send subscriptions after auth (relay may have ignored pre-auth requests)
693
+ // Re-send subscriptions after auth (relay may have ignored pre-auth
694
+ // requests). Two separate per-relay markers, two separate decisions:
695
+ //
696
+ // - `closedSubIds`: do NOT clear. handleClosedMessage already
697
+ // skips the auth-required transient case, so anything in this
698
+ // set is a TERMINAL rejection (rate-limited, blocked, etc.)
699
+ // that AUTH does not relax. The resubscribeAll guard then
700
+ // correctly skips terminal-rejected subs on this relay. They
701
+ // will be retried on the next reconnect, when onopen creates a
702
+ // fresh RelayConnection with empty markers.
703
+ //
704
+ // - `eosedSubIds`: clear. A relay may have EOSE'd a pre-auth sub
705
+ // with zero events (filter unsatisfiable without auth context);
706
+ // post-auth the same filter might match. We must re-arm the
707
+ // local "still waiting" state so any in-flight
708
+ // queryWithFirstSeenWins doesn't see this relay as already-done
709
+ // from a stale marker.
482
710
  setTimeout(() => {
711
+ const r = this.relays.get(relayUrl);
712
+ if (r)
713
+ r.eosedSubIds.clear();
483
714
  this.resubscribeAll(relayUrl);
484
715
  }, AUTH_RESUBSCRIBE_DELAY_MS);
485
716
  }
@@ -499,24 +730,40 @@ export class NostrClient {
499
730
  item.reject(new Error('Client disconnected'));
500
731
  }
501
732
  this.eventQueue = [];
502
- // Close all relay connections and clean up timers
733
+ // Close all relay connections and clean up timers. Mark every
734
+ // relay disconnected synchronously BEFORE we notify subscriptions
735
+ // below, so any listener that consults `allRelaysDoneFor` sees
736
+ // zero connected relays and settles immediately.
503
737
  for (const [url, relay] of this.relays) {
504
- // Stop ping timer
738
+ relay.connected = false;
505
739
  if (relay.pingTimer) {
506
740
  clearInterval(relay.pingTimer);
507
741
  relay.pingTimer = null;
508
742
  }
509
- // Stop reconnect timer
510
743
  if (relay.reconnectTimer) {
511
744
  clearTimeout(relay.reconnectTimer);
512
745
  relay.reconnectTimer = null;
513
746
  }
514
- // Close socket
515
747
  if (relay.socket && relay.socket.readyState !== CLOSED) {
516
748
  relay.socket.close(1000, 'Client disconnected');
517
749
  }
518
750
  this.emitConnectionEvent('disconnect', url, 'Client disconnected');
519
751
  }
752
+ // Notify in-flight subscriptions that we're shutting down.
753
+ // queryWithFirstSeenWins.onError re-checks allRelaysDoneFor (now
754
+ // 0 connected → trivially true) and settles immediately, sparing
755
+ // callers the full queryTimeoutMs wait. Snapshot keys first
756
+ // because the listener may call unsubscribe(), which mutates
757
+ // this.subscriptions while we iterate.
758
+ const inflightSubs = Array.from(this.subscriptions.entries());
759
+ for (const [subId, sub] of inflightSubs) {
760
+ try {
761
+ sub.listener.onError?.(subId, 'Client disconnected');
762
+ }
763
+ catch {
764
+ // Ignore listener errors — we're tearing down anyway.
765
+ }
766
+ }
520
767
  this.relays.clear();
521
768
  this.subscriptions.clear();
522
769
  }
@@ -718,7 +965,22 @@ export class NostrClient {
718
965
  filter = filterOrSubId;
719
966
  listener = listenerOrFilter;
720
967
  }
968
+ // Reserved prefix for SDK-internal sub_ids (currently just the
969
+ // keepalive `PING_SUB_ID`). Reject explicit caller use so the
970
+ // keepalive timer's CLOSE/REQ cycle can't stomp on user state.
971
+ if (subscriptionId.startsWith('__nostr-sdk-')) {
972
+ throw new Error(`Subscription ID "${subscriptionId}" uses the reserved "__nostr-sdk-" prefix — pick a different id.`);
973
+ }
721
974
  this.subscriptions.set(subscriptionId, { filter, listener });
975
+ // Wipe any stale per-relay EOSE/CLOSED markers for this sub_id
976
+ // before issuing the REQ — otherwise a fresh subscribe with a
977
+ // sub_id that was previously CLOSED (or was just freshly
978
+ // EOSE'd) would be skipped or treated as "already done" on
979
+ // those relays.
980
+ for (const [, relay] of this.relays) {
981
+ relay.closedSubIds.delete(subscriptionId);
982
+ relay.eosedSubIds.delete(subscriptionId);
983
+ }
722
984
  // Send subscription request to all connected relays
723
985
  const message = JSON.stringify(['REQ', subscriptionId, filter.toJSON()]);
724
986
  for (const [, relay] of this.relays) {
@@ -736,12 +998,19 @@ export class NostrClient {
736
998
  if (!this.subscriptions.has(subscriptionId))
737
999
  return;
738
1000
  this.subscriptions.delete(subscriptionId);
739
- // Send CLOSE to all connected relays
1001
+ // Send CLOSE to all connected relays — except those that already
1002
+ // CLOSED the sub themselves (no point telling the relay something
1003
+ // it told us).
740
1004
  const message = JSON.stringify(['CLOSE', subscriptionId]);
741
1005
  for (const [, relay] of this.relays) {
742
- if (relay.connected && relay.socket?.readyState === OPEN) {
1006
+ if (relay.connected && relay.socket?.readyState === OPEN
1007
+ && !relay.closedSubIds.has(subscriptionId)) {
743
1008
  relay.socket.send(message);
744
1009
  }
1010
+ // Drop both per-relay markers now that the sub is gone from
1011
+ // the global map.
1012
+ relay.closedSubIds.delete(subscriptionId);
1013
+ relay.eosedSubIds.delete(subscriptionId);
745
1014
  }
746
1015
  }
747
1016
  /**
@@ -767,12 +1036,45 @@ export class NostrClient {
767
1036
  queryWithFirstSeenWins(filter, extractResult) {
768
1037
  return new Promise((resolve) => {
769
1038
  let subscriptionId = '';
770
- const timeoutId = setTimeout(() => {
771
- if (subscriptionId)
772
- this.unsubscribe(subscriptionId);
773
- resolve(null);
774
- }, this.queryTimeoutMs);
1039
+ let settled = false;
1040
+ // Declared as `let` and initialized lazily so `finishWith` can be
1041
+ // invoked before the setTimeout call below without hitting the
1042
+ // TDZ on `clearTimeout(timeoutId)`. (The same comment on the
1043
+ // listener anticipates synchronous-callback hypothetical paths.)
1044
+ let timeoutId;
1045
+ // Accept an explicit `id` so callers from inside the listener can
1046
+ // pass the sub_id the relay echoed back. This guards against any
1047
+ // future change to subscribe() that would invoke listener
1048
+ // callbacks before its return value is bound to `subscriptionId`
1049
+ // — the closure-captured value would still be `''` and we'd skip
1050
+ // the CLOSE frame, leaking the slot on the relay.
1051
+ const finishWith = (result, id) => {
1052
+ if (settled)
1053
+ return;
1054
+ settled = true;
1055
+ if (timeoutId !== undefined)
1056
+ clearTimeout(timeoutId);
1057
+ const subId = id || subscriptionId;
1058
+ if (subId)
1059
+ this.unsubscribe(subId);
1060
+ resolve(result);
1061
+ };
1062
+ timeoutId = setTimeout(() => finishWith(null), this.queryTimeoutMs);
775
1063
  const authors = new Map();
1064
+ const allRelaysDone = (id) => this.allRelaysDoneFor(id);
1065
+ const pickWinner = () => {
1066
+ let winnerEntry = null;
1067
+ let winnerPubkey = '';
1068
+ for (const [pubkey, entry] of authors) {
1069
+ if (!winnerEntry
1070
+ || entry.firstSeen < winnerEntry.firstSeen
1071
+ || (entry.firstSeen === winnerEntry.firstSeen && pubkey < winnerPubkey)) {
1072
+ winnerEntry = entry;
1073
+ winnerPubkey = pubkey;
1074
+ }
1075
+ }
1076
+ return winnerEntry ? extractResult(winnerEntry.latestEvent) : null;
1077
+ };
776
1078
  subscriptionId = this.subscribe(filter, {
777
1079
  onEvent: (event) => {
778
1080
  // Verify signature to prevent relay injection of forged events (#4)
@@ -791,24 +1093,55 @@ export class NostrClient {
791
1093
  }
792
1094
  }
793
1095
  },
794
- onEndOfStoredEvents: () => {
795
- clearTimeout(timeoutId);
796
- this.unsubscribe(subscriptionId);
797
- let winnerEntry = null;
798
- let winnerPubkey = '';
799
- for (const [pubkey, entry] of authors) {
800
- if (!winnerEntry
801
- || entry.firstSeen < winnerEntry.firstSeen
802
- || (entry.firstSeen === winnerEntry.firstSeen && pubkey < winnerPubkey)) {
803
- winnerEntry = entry;
804
- winnerPubkey = pubkey;
805
- }
1096
+ // EOSE means *this relay* has finished delivering stored
1097
+ // events. In a multi-relay client we must not settle yet — a
1098
+ // slower relay may still be about to deliver matching events.
1099
+ // Settle only when every connected relay has either EOSE'd
1100
+ // OR CLOSED'd this sub. (Single-relay clients are unaffected:
1101
+ // allDone is trivially true with one relay.)
1102
+ onEndOfStoredEvents: (id) => {
1103
+ if (allRelaysDone(id)) {
1104
+ finishWith(pickWinner(), id);
1105
+ }
1106
+ },
1107
+ // Subscription error from the SDK — fires from three paths
1108
+ // that all need the same "is it time to settle?" check:
1109
+ // 1. Relay sent CLOSED for this sub. In a multi-relay
1110
+ // client the same sub_id may still be alive on a
1111
+ // healthy relay; settling on the first CLOSED would
1112
+ // prematurely abort a query other relays could
1113
+ // satisfy. handleClosedMessage records the rejection
1114
+ // on the sending relay's closedSubIds before invoking
1115
+ // us, so we can decide via allRelaysDoneFor.
1116
+ // 2. Relay disconnected mid-query (socket.onclose →
1117
+ // synthetic onError). The relay no longer counts as
1118
+ // connected, so allRelaysDoneFor excludes it.
1119
+ // 3. Client disconnected (disconnect() → synthetic
1120
+ // onError). All relays are torn down, allRelaysDoneFor
1121
+ // sees zero connected and settles.
1122
+ onError: (id, message) => {
1123
+ console.warn(`Subscription error on ${id}: ${message}`);
1124
+ if (allRelaysDone(id)) {
1125
+ finishWith(pickWinner(), id);
806
1126
  }
807
- resolve(winnerEntry ? extractResult(winnerEntry.latestEvent) : null);
1127
+ // else: keep waiting for EOSE / CLOSED from remaining
1128
+ // relays or the overall query timeout.
808
1129
  },
809
1130
  });
810
1131
  });
811
1132
  }
1133
+ /**
1134
+ * True if every currently-connected relay has finished delivering
1135
+ * for the given sub_id (either EOSE'd or CLOSED'd it). Used by
1136
+ * queryWithFirstSeenWins to coordinate multi-relay settlement.
1137
+ */
1138
+ allRelaysDoneFor(subscriptionId) {
1139
+ const connected = Array.from(this.relays.values()).filter((r) => r.connected);
1140
+ // No connected relays at all → nothing to wait for; settle.
1141
+ if (connected.length === 0)
1142
+ return true;
1143
+ return connected.every((r) => r.eosedSubIds.has(subscriptionId) || r.closedSubIds.has(subscriptionId));
1144
+ }
812
1145
  /**
813
1146
  * Query for a public key by nametag.
814
1147
  * Uses first-seen-wins anti-hijacking resolution.