@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.
@@ -9532,6 +9532,35 @@ const DEFAULT_QUERY_TIMEOUT_MS = 5000;
9532
9532
  const DEFAULT_RECONNECT_INTERVAL_MS = 1000;
9533
9533
  const DEFAULT_MAX_RECONNECT_INTERVAL_MS = 30000;
9534
9534
  const DEFAULT_PING_INTERVAL_MS = 30000;
9535
+ /**
9536
+ * Internal sub_id reserved for the keepalive REQ. Namespaced with a
9537
+ * `__nostr-sdk-` prefix so that user code calling
9538
+ * {@link NostrClient.subscribe} with an explicit `subscriptionId`
9539
+ * cannot collide — a user choosing the literal `"ping"` would
9540
+ * otherwise have their subscription forcibly CLOSE/REQ'd every
9541
+ * ping interval. The leading `__` is a stable convention for
9542
+ * "do not pick this name."
9543
+ */
9544
+ const PING_SUB_ID = '__nostr-sdk-keepalive__';
9545
+ /**
9546
+ * Filter id used by the keepalive REQ. We need a filter the relay can
9547
+ * resolve immediately (so EOSE comes back fast = relay is alive), but
9548
+ * which can NOT match any real event past EOSE (so the live tail stays
9549
+ * empty).
9550
+ *
9551
+ * Scoping by `authors:[selfPubkey]` was the original approach but it
9552
+ * matched every event the wallet itself published — including kind-31113
9553
+ * token transfers — which the relay then echoed back on the keepalive
9554
+ * sub. Some relays dedupe events across overlapping subs, so the
9555
+ * wallet's own consumer subscription would not receive its own echo and
9556
+ * any flow waiting on that echo would time out.
9557
+ *
9558
+ * The filter `{ ids: ['00...00'] }` asks the relay for a single event
9559
+ * whose id is exactly the all-zero hash. Real Nostr event ids are
9560
+ * SHA-256 over a canonical JSON serialization, so the all-zero hash is
9561
+ * unreachable in practice. Result: instant EOSE, empty live tail.
9562
+ */
9563
+ const KEEPALIVE_NEVER_MATCH_ID = '0'.repeat(64);
9535
9564
  /**
9536
9565
  * Delay before resubscribing after NIP-42 authentication.
9537
9566
  * This gives the relay time to process the AUTH response before we send
@@ -9571,6 +9600,30 @@ class NostrClient {
9571
9600
  this.maxReconnectIntervalMs = options?.maxReconnectIntervalMs ?? DEFAULT_MAX_RECONNECT_INTERVAL_MS;
9572
9601
  this.pingIntervalMs = options?.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
9573
9602
  }
9603
+ /**
9604
+ * Replace the key manager used for signing and encryption.
9605
+ *
9606
+ * The connection stays alive — but every operation that consults the
9607
+ * key manager from this point on uses the new key, including:
9608
+ * - signing future published events,
9609
+ * - signing NIP-42 AUTH challenge responses,
9610
+ * - the `authors:[selfPubkey]` filter on the keepalive ping REQ
9611
+ * (computed each ping interval),
9612
+ * - any other code path that calls `getPublicKeyHex()` on the
9613
+ * stored manager.
9614
+ *
9615
+ * Existing in-flight subscriptions are not re-issued or re-keyed.
9616
+ * @param keyManager New key manager
9617
+ */
9618
+ setKeyManager(keyManager) {
9619
+ this.keyManager = keyManager;
9620
+ }
9621
+ /**
9622
+ * Get the current key manager.
9623
+ */
9624
+ getKeyManager() {
9625
+ return this.keyManager;
9626
+ }
9574
9627
  /**
9575
9628
  * Add a connection event listener.
9576
9629
  * @param listener Listener for connection events
@@ -9614,13 +9667,6 @@ class NostrClient {
9614
9667
  }
9615
9668
  }
9616
9669
  }
9617
- /**
9618
- * Get the key manager.
9619
- * @returns The key manager instance
9620
- */
9621
- getKeyManager() {
9622
- return this.keyManager;
9623
- }
9624
9670
  /**
9625
9671
  * Get the current query timeout in milliseconds.
9626
9672
  * @returns Query timeout in milliseconds
@@ -9657,11 +9703,41 @@ class NostrClient {
9657
9703
  return;
9658
9704
  }
9659
9705
  return new Promise((resolve, reject) => {
9706
+ // The connection-setup timeout has three races to defend
9707
+ // against:
9708
+ // A) createWebSocket resolves AFTER the timeout fired.
9709
+ // B) createWebSocket resolves BEFORE the timeout, but
9710
+ // `onopen` fires AFTER the timeout fired.
9711
+ // C) createWebSocket resolves and `onopen` fires BEFORE the
9712
+ // timeout (the success path).
9713
+ // `pendingSocket` lets the timeout proactively close any
9714
+ // socket that's already been created but hasn't fired
9715
+ // `onopen` yet. The `timedOut` flag covers (A) inside `.then`
9716
+ // and (B) inside `socket.onopen`.
9717
+ let timedOut = false;
9718
+ let pendingSocket = null;
9660
9719
  const timeoutId = setTimeout(() => {
9720
+ timedOut = true;
9721
+ if (pendingSocket) {
9722
+ try {
9723
+ pendingSocket.close(1000, 'Connection setup timed out');
9724
+ }
9725
+ catch { /* ignore */ }
9726
+ }
9661
9727
  reject(new Error(`Connection to ${url} timed out`));
9662
9728
  }, CONNECTION_TIMEOUT_MS);
9663
9729
  createWebSocket(url)
9664
9730
  .then((socket) => {
9731
+ if (timedOut) {
9732
+ // Caller already saw the rejection. Discard the late
9733
+ // socket so we don't leak it.
9734
+ try {
9735
+ socket.close(1000, 'Connection setup timed out');
9736
+ }
9737
+ catch { /* ignore */ }
9738
+ return;
9739
+ }
9740
+ pendingSocket = socket;
9665
9741
  const relay = {
9666
9742
  url,
9667
9743
  socket,
@@ -9673,8 +9749,28 @@ class NostrClient {
9673
9749
  lastPongTime: Date.now(),
9674
9750
  unansweredPings: 0,
9675
9751
  wasConnected: existingRelay?.wasConnected ?? false,
9752
+ // Reset on every new connection: a relay's per-connection
9753
+ // sub-slot accounting is fresh, so previously-rejected REQs
9754
+ // should be re-issued on the new socket.
9755
+ closedSubIds: new Set(),
9756
+ eosedSubIds: new Set(),
9676
9757
  };
9677
9758
  socket.onopen = () => {
9759
+ // The `.then` block already guards against a socket
9760
+ // arriving after the connection timeout, but the socket
9761
+ // can also be created BEFORE the timeout while
9762
+ // `onopen` fires AFTER the timeout has rejected the
9763
+ // outer promise. Without this second guard we'd register
9764
+ // the relay, start a pingTimer, and resubscribe — orphan
9765
+ // background resources the caller can't see or clean up
9766
+ // because their connect() call already saw a rejection.
9767
+ if (timedOut) {
9768
+ try {
9769
+ socket.close(1000, 'Connection setup timed out');
9770
+ }
9771
+ catch { /* ignore */ }
9772
+ return;
9773
+ }
9678
9774
  clearTimeout(timeoutId);
9679
9775
  relay.connected = true;
9680
9776
  relay.reconnectAttempts = 0; // Reset on successful connection
@@ -9715,9 +9811,40 @@ class NostrClient {
9715
9811
  const wasConnected = relay.connected;
9716
9812
  relay.connected = false;
9717
9813
  this.stopPingTimer(url);
9814
+ // Pre-onopen close: TCP handshake failure or relay
9815
+ // immediately closed the WS during the upgrade. Without
9816
+ // this, the connectToRelay promise stays pending until
9817
+ // CONNECTION_TIMEOUT_MS (30s) expires; surfacing it now
9818
+ // lets the caller see the failure promptly and retry.
9819
+ if (!wasConnected && !timedOut) {
9820
+ timedOut = true;
9821
+ clearTimeout(timeoutId);
9822
+ reject(new Error(`Connection to ${url} closed during handshake: ${event?.reason || 'no reason'}`));
9823
+ }
9718
9824
  if (wasConnected) {
9719
9825
  const reason = event?.reason || 'Connection closed';
9720
9826
  this.emitConnectionEvent('disconnect', url, reason);
9827
+ // Re-trigger the all-done check on every active sub.
9828
+ // queryWithFirstSeenWins.allRelaysDoneFor only runs
9829
+ // from listener callbacks (EOSE / CLOSED via onError);
9830
+ // a socket that drops without sending either would
9831
+ // otherwise leave the query hanging until
9832
+ // queryTimeoutMs even though the disconnected relay no
9833
+ // longer counts toward "still pending" relays. Firing
9834
+ // a synthetic onError gives every active sub a chance
9835
+ // to re-evaluate now that the relay set has shrunk.
9836
+ // Include the relay URL so listeners in a multi-relay
9837
+ // client can attribute which relay dropped.
9838
+ const inflight = Array.from(this.subscriptions.entries());
9839
+ for (const [subId, sub] of inflight) {
9840
+ try {
9841
+ sub.listener.onError?.(subId, `Relay disconnected (${url}): ${reason}`);
9842
+ }
9843
+ catch {
9844
+ // Ignore listener errors — we're notifying
9845
+ // best-effort.
9846
+ }
9847
+ }
9721
9848
  }
9722
9849
  if (!this.closed && this.autoReconnect && !relay.reconnecting) {
9723
9850
  this.scheduleReconnect(url);
@@ -9729,7 +9856,11 @@ class NostrClient {
9729
9856
  reject(new Error(`Failed to connect to ${url}: ${error.message || 'Unknown error'}`));
9730
9857
  }
9731
9858
  };
9732
- this.relays.set(url, relay);
9859
+ // Note: we do NOT register the relay in `this.relays` here —
9860
+ // only after `onopen` fires successfully. Registering eagerly
9861
+ // (before onopen) would leak the relay into the global map
9862
+ // even when the connection setup times out and the caller's
9863
+ // promise has already rejected.
9733
9864
  })
9734
9865
  .catch((error) => {
9735
9866
  clearTimeout(timeoutId);
@@ -9806,16 +9937,31 @@ class NostrClient {
9806
9937
  }
9807
9938
  return;
9808
9939
  }
9809
- // Send a subscription request as a ping (relays respond with EOSE)
9810
- // Use a single fixed subscription ID per relay to avoid accumulating subscriptions
9811
- // Note: limit:1 is used because some relays don't respond to limit:0
9940
+ // Send a subscription request as a ping (relays respond with EOSE).
9941
+ // The filter MUST be tightly scoped to something no real event can
9942
+ // match both for the initial query AND the post-EOSE live tail.
9943
+ //
9944
+ // Earlier iterations used `authors:[self]` reasoning that "the
9945
+ // relay would only forward our own future events". That reasoning
9946
+ // was wrong: it precisely DOES forward every event the wallet
9947
+ // publishes, including kind-31113 token transfers. Some relays
9948
+ // dedupe events across overlapping subs, so the wallet's own
9949
+ // consumer subscription would miss its echo and any flow waiting
9950
+ // on it would time out.
9951
+ //
9952
+ // {@link KEEPALIVE_NEVER_MATCH_ID} (the all-zero SHA-256 hash) is
9953
+ // unreachable in real event-id space, so the relay returns EOSE
9954
+ // immediately and the live tail never matches.
9812
9955
  try {
9813
- const pingSubId = `ping`;
9814
9956
  // First close any existing ping subscription to ensure we don't accumulate
9815
- const closeMessage = JSON.stringify(['CLOSE', pingSubId]);
9957
+ const closeMessage = JSON.stringify(['CLOSE', PING_SUB_ID]);
9816
9958
  relay.socket.send(closeMessage);
9817
9959
  // Then send the new ping request (limit:1 ensures relay sends EOSE)
9818
- const pingMessage = JSON.stringify(['REQ', pingSubId, { limit: 1 }]);
9960
+ const pingMessage = JSON.stringify([
9961
+ 'REQ',
9962
+ PING_SUB_ID,
9963
+ { ids: [KEEPALIVE_NEVER_MATCH_ID], limit: 1 },
9964
+ ]);
9819
9965
  relay.socket.send(pingMessage);
9820
9966
  relay.unansweredPings++;
9821
9967
  }
@@ -9850,6 +9996,11 @@ class NostrClient {
9850
9996
  if (!relay?.socket || !relay.connected)
9851
9997
  return;
9852
9998
  for (const [subId, info] of this.subscriptions) {
9999
+ // Skip subs this relay has previously CLOSED — re-issuing them
10000
+ // just triggers the same rejection in a loop. Other healthy
10001
+ // relays still resubscribe.
10002
+ if (relay.closedSubIds.has(subId))
10003
+ continue;
9853
10004
  const message = JSON.stringify(['REQ', subId, info.filter.toJSON()]);
9854
10005
  relay.socket.send(message);
9855
10006
  }
@@ -9869,7 +10020,7 @@ class NostrClient {
9869
10020
  /**
9870
10021
  * Handle a message from a relay.
9871
10022
  */
9872
- handleRelayMessage(_url, message) {
10023
+ handleRelayMessage(relayUrl, message) {
9873
10024
  try {
9874
10025
  const json = JSON.parse(message);
9875
10026
  if (!Array.isArray(json) || json.length < 2)
@@ -9883,16 +10034,16 @@ class NostrClient {
9883
10034
  this.handleOkMessage(json);
9884
10035
  break;
9885
10036
  case 'EOSE':
9886
- this.handleEOSEMessage(json);
10037
+ this.handleEOSEMessage(relayUrl, json);
9887
10038
  break;
9888
10039
  case 'NOTICE':
9889
10040
  this.handleNoticeMessage(json);
9890
10041
  break;
9891
10042
  case 'CLOSED':
9892
- this.handleClosedMessage(json);
10043
+ this.handleClosedMessage(relayUrl, json);
9893
10044
  break;
9894
10045
  case 'AUTH':
9895
- this.handleAuthMessage(_url, json);
10046
+ this.handleAuthMessage(relayUrl, json);
9896
10047
  break;
9897
10048
  }
9898
10049
  }
@@ -9904,7 +10055,7 @@ class NostrClient {
9904
10055
  * Handle EVENT message from relay.
9905
10056
  */
9906
10057
  handleEventMessage(json) {
9907
- if (json.length < 3)
10058
+ if (json.length < 3 || typeof json[1] !== 'string')
9908
10059
  return;
9909
10060
  const subscriptionId = json[1];
9910
10061
  const eventData = json[2];
@@ -9942,11 +10093,23 @@ class NostrClient {
9942
10093
  }
9943
10094
  /**
9944
10095
  * Handle EOSE (End of Stored Events) message from relay.
10096
+ *
10097
+ * Records the per-relay EOSE marker (mirroring closedSubIds) so
10098
+ * queryWithFirstSeenWins can decide when ALL connected relays have
10099
+ * finished — either streamed EOSE or rejected with CLOSED — instead
10100
+ * of settling off the first fast relay's EOSE while a slower relay
10101
+ * is still about to deliver matching events.
9945
10102
  */
9946
- handleEOSEMessage(json) {
9947
- if (json.length < 2)
10103
+ handleEOSEMessage(relayUrl, json) {
10104
+ if (json.length < 2 || typeof json[1] !== 'string')
9948
10105
  return;
9949
10106
  const subscriptionId = json[1];
10107
+ if (!this.subscriptions.has(subscriptionId))
10108
+ return;
10109
+ const relay = this.relays.get(relayUrl);
10110
+ if (relay) {
10111
+ relay.eosedSubIds.add(subscriptionId);
10112
+ }
9950
10113
  const subscription = this.subscriptions.get(subscriptionId);
9951
10114
  if (subscription?.listener.onEndOfStoredEvents) {
9952
10115
  subscription.listener.onEndOfStoredEvents(subscriptionId);
@@ -9963,15 +10126,64 @@ class NostrClient {
9963
10126
  }
9964
10127
  /**
9965
10128
  * Handle CLOSED message from relay (subscription closed by relay).
10129
+ *
10130
+ * NIP-01 CLOSED frames are terminal for the named subscription **on
10131
+ * the sending relay**. In a multi-relay client the same sub_id may
10132
+ * still be alive on a healthy relay, so we must NOT delete the
10133
+ * global `this.subscriptions` entry here — that would silently drop
10134
+ * EVENT/EOSE frames from the still-healthy relays in
10135
+ * `handleEventMessage` (which consults the global map).
10136
+ *
10137
+ * Instead we record the rejection on the sending relay's
10138
+ * `closedSubIds` set so `resubscribeAll` and post-AUTH resubscribe
10139
+ * skip it on this relay only. The listener is notified via
10140
+ * `onError` so callers (e.g. queryWithFirstSeenWins) can decide to
10141
+ * settle and explicitly `unsubscribe()` if they want to give up
10142
+ * across all relays.
9966
10143
  */
9967
- handleClosedMessage(json) {
9968
- if (json.length < 3)
10144
+ handleClosedMessage(relayUrl, json) {
10145
+ // NIP-01 makes the message field optional: `["CLOSED", <sub>]` is
10146
+ // valid. Dropping such frames was exactly the leak this PR sets out
10147
+ // to fix — no closedSubIds marker and no onError notification means
10148
+ // queries hang until timeout and resubscribe loops persist.
10149
+ if (json.length < 2 || typeof json[1] !== 'string')
9969
10150
  return;
9970
10151
  const subscriptionId = json[1];
9971
- const message = json[2];
10152
+ // Ignore CLOSED for sub_ids we don't know about. A misbehaving or
10153
+ // malicious relay could otherwise spam us with arbitrary sub_ids
10154
+ // and grow `closedSubIds` unbounded over a long-lived connection,
10155
+ // and could pre-emptively block sub_ids we might use later.
10156
+ if (!this.subscriptions.has(subscriptionId))
10157
+ return;
10158
+ const message = typeof json[2] === 'string' ? json[2] : 'no reason provided';
10159
+ // NIP-42 transient case: relays that require AUTH typically reject
10160
+ // pre-auth REQs with `CLOSED("auth-required:...")` and then send
10161
+ // an AUTH challenge. resubscribeAfterAuth re-issues the sub, so
10162
+ // this rejection is NOT terminal. If we marked closedSubIds here,
10163
+ // queryWithFirstSeenWins.onError would settle the future
10164
+ // prematurely (single-relay → allRelaysDoneFor=true), unsubscribe
10165
+ // the sub, and the post-AUTH retry would find nothing to retry.
10166
+ // Listener still gets onError so callers see the reason; we just
10167
+ // don't poison the per-relay state with a transient marker.
10168
+ //
10169
+ // We accept three on-the-wire shapes: `auth-required:...`
10170
+ // (NIP-42 standard with reason), `auth-required ...` (whitespace
10171
+ // separator), and bare `auth-required` (no suffix at all — some
10172
+ // relays / tests).
10173
+ const isAuthRequired = message === 'auth-required'
10174
+ || message.startsWith('auth-required:')
10175
+ || message.startsWith('auth-required ');
10176
+ const relay = this.relays.get(relayUrl);
10177
+ if (relay && !isAuthRequired) {
10178
+ relay.closedSubIds.add(subscriptionId);
10179
+ }
9972
10180
  const subscription = this.subscriptions.get(subscriptionId);
9973
10181
  if (subscription?.listener.onError) {
9974
- subscription.listener.onError(subscriptionId, `Subscription closed: ${message}`);
10182
+ // Pass the relay's reason through verbatim so callers can
10183
+ // pattern-match on standard prefixes (`auth-required:`,
10184
+ // `rate-limited:`, `blocked:`, etc.) without parsing through
10185
+ // a wrapper string.
10186
+ subscription.listener.onError(subscriptionId, message);
9975
10187
  }
9976
10188
  }
9977
10189
  /**
@@ -9996,8 +10208,27 @@ class NostrClient {
9996
10208
  // Send AUTH response
9997
10209
  const message = JSON.stringify(['AUTH', authEvent.toJSON()]);
9998
10210
  relay.socket.send(message);
9999
- // Re-send subscriptions after auth (relay may have ignored pre-auth requests)
10211
+ // Re-send subscriptions after auth (relay may have ignored pre-auth
10212
+ // requests). Two separate per-relay markers, two separate decisions:
10213
+ //
10214
+ // - `closedSubIds`: do NOT clear. handleClosedMessage already
10215
+ // skips the auth-required transient case, so anything in this
10216
+ // set is a TERMINAL rejection (rate-limited, blocked, etc.)
10217
+ // that AUTH does not relax. The resubscribeAll guard then
10218
+ // correctly skips terminal-rejected subs on this relay. They
10219
+ // will be retried on the next reconnect, when onopen creates a
10220
+ // fresh RelayConnection with empty markers.
10221
+ //
10222
+ // - `eosedSubIds`: clear. A relay may have EOSE'd a pre-auth sub
10223
+ // with zero events (filter unsatisfiable without auth context);
10224
+ // post-auth the same filter might match. We must re-arm the
10225
+ // local "still waiting" state so any in-flight
10226
+ // queryWithFirstSeenWins doesn't see this relay as already-done
10227
+ // from a stale marker.
10000
10228
  setTimeout(() => {
10229
+ const r = this.relays.get(relayUrl);
10230
+ if (r)
10231
+ r.eosedSubIds.clear();
10001
10232
  this.resubscribeAll(relayUrl);
10002
10233
  }, AUTH_RESUBSCRIBE_DELAY_MS);
10003
10234
  }
@@ -10017,24 +10248,40 @@ class NostrClient {
10017
10248
  item.reject(new Error('Client disconnected'));
10018
10249
  }
10019
10250
  this.eventQueue = [];
10020
- // Close all relay connections and clean up timers
10251
+ // Close all relay connections and clean up timers. Mark every
10252
+ // relay disconnected synchronously BEFORE we notify subscriptions
10253
+ // below, so any listener that consults `allRelaysDoneFor` sees
10254
+ // zero connected relays and settles immediately.
10021
10255
  for (const [url, relay] of this.relays) {
10022
- // Stop ping timer
10256
+ relay.connected = false;
10023
10257
  if (relay.pingTimer) {
10024
10258
  clearInterval(relay.pingTimer);
10025
10259
  relay.pingTimer = null;
10026
10260
  }
10027
- // Stop reconnect timer
10028
10261
  if (relay.reconnectTimer) {
10029
10262
  clearTimeout(relay.reconnectTimer);
10030
10263
  relay.reconnectTimer = null;
10031
10264
  }
10032
- // Close socket
10033
10265
  if (relay.socket && relay.socket.readyState !== CLOSED) {
10034
10266
  relay.socket.close(1000, 'Client disconnected');
10035
10267
  }
10036
10268
  this.emitConnectionEvent('disconnect', url, 'Client disconnected');
10037
10269
  }
10270
+ // Notify in-flight subscriptions that we're shutting down.
10271
+ // queryWithFirstSeenWins.onError re-checks allRelaysDoneFor (now
10272
+ // 0 connected → trivially true) and settles immediately, sparing
10273
+ // callers the full queryTimeoutMs wait. Snapshot keys first
10274
+ // because the listener may call unsubscribe(), which mutates
10275
+ // this.subscriptions while we iterate.
10276
+ const inflightSubs = Array.from(this.subscriptions.entries());
10277
+ for (const [subId, sub] of inflightSubs) {
10278
+ try {
10279
+ sub.listener.onError?.(subId, 'Client disconnected');
10280
+ }
10281
+ catch {
10282
+ // Ignore listener errors — we're tearing down anyway.
10283
+ }
10284
+ }
10038
10285
  this.relays.clear();
10039
10286
  this.subscriptions.clear();
10040
10287
  }
@@ -10236,7 +10483,22 @@ class NostrClient {
10236
10483
  filter = filterOrSubId;
10237
10484
  listener = listenerOrFilter;
10238
10485
  }
10486
+ // Reserved prefix for SDK-internal sub_ids (currently just the
10487
+ // keepalive `PING_SUB_ID`). Reject explicit caller use so the
10488
+ // keepalive timer's CLOSE/REQ cycle can't stomp on user state.
10489
+ if (subscriptionId.startsWith('__nostr-sdk-')) {
10490
+ throw new Error(`Subscription ID "${subscriptionId}" uses the reserved "__nostr-sdk-" prefix — pick a different id.`);
10491
+ }
10239
10492
  this.subscriptions.set(subscriptionId, { filter, listener });
10493
+ // Wipe any stale per-relay EOSE/CLOSED markers for this sub_id
10494
+ // before issuing the REQ — otherwise a fresh subscribe with a
10495
+ // sub_id that was previously CLOSED (or was just freshly
10496
+ // EOSE'd) would be skipped or treated as "already done" on
10497
+ // those relays.
10498
+ for (const [, relay] of this.relays) {
10499
+ relay.closedSubIds.delete(subscriptionId);
10500
+ relay.eosedSubIds.delete(subscriptionId);
10501
+ }
10240
10502
  // Send subscription request to all connected relays
10241
10503
  const message = JSON.stringify(['REQ', subscriptionId, filter.toJSON()]);
10242
10504
  for (const [, relay] of this.relays) {
@@ -10254,12 +10516,19 @@ class NostrClient {
10254
10516
  if (!this.subscriptions.has(subscriptionId))
10255
10517
  return;
10256
10518
  this.subscriptions.delete(subscriptionId);
10257
- // Send CLOSE to all connected relays
10519
+ // Send CLOSE to all connected relays — except those that already
10520
+ // CLOSED the sub themselves (no point telling the relay something
10521
+ // it told us).
10258
10522
  const message = JSON.stringify(['CLOSE', subscriptionId]);
10259
10523
  for (const [, relay] of this.relays) {
10260
- if (relay.connected && relay.socket?.readyState === OPEN) {
10524
+ if (relay.connected && relay.socket?.readyState === OPEN
10525
+ && !relay.closedSubIds.has(subscriptionId)) {
10261
10526
  relay.socket.send(message);
10262
10527
  }
10528
+ // Drop both per-relay markers now that the sub is gone from
10529
+ // the global map.
10530
+ relay.closedSubIds.delete(subscriptionId);
10531
+ relay.eosedSubIds.delete(subscriptionId);
10263
10532
  }
10264
10533
  }
10265
10534
  /**
@@ -10285,12 +10554,45 @@ class NostrClient {
10285
10554
  queryWithFirstSeenWins(filter, extractResult) {
10286
10555
  return new Promise((resolve) => {
10287
10556
  let subscriptionId = '';
10288
- const timeoutId = setTimeout(() => {
10289
- if (subscriptionId)
10290
- this.unsubscribe(subscriptionId);
10291
- resolve(null);
10292
- }, this.queryTimeoutMs);
10557
+ let settled = false;
10558
+ // Declared as `let` and initialized lazily so `finishWith` can be
10559
+ // invoked before the setTimeout call below without hitting the
10560
+ // TDZ on `clearTimeout(timeoutId)`. (The same comment on the
10561
+ // listener anticipates synchronous-callback hypothetical paths.)
10562
+ let timeoutId;
10563
+ // Accept an explicit `id` so callers from inside the listener can
10564
+ // pass the sub_id the relay echoed back. This guards against any
10565
+ // future change to subscribe() that would invoke listener
10566
+ // callbacks before its return value is bound to `subscriptionId`
10567
+ // — the closure-captured value would still be `''` and we'd skip
10568
+ // the CLOSE frame, leaking the slot on the relay.
10569
+ const finishWith = (result, id) => {
10570
+ if (settled)
10571
+ return;
10572
+ settled = true;
10573
+ if (timeoutId !== undefined)
10574
+ clearTimeout(timeoutId);
10575
+ const subId = id || subscriptionId;
10576
+ if (subId)
10577
+ this.unsubscribe(subId);
10578
+ resolve(result);
10579
+ };
10580
+ timeoutId = setTimeout(() => finishWith(null), this.queryTimeoutMs);
10293
10581
  const authors = new Map();
10582
+ const allRelaysDone = (id) => this.allRelaysDoneFor(id);
10583
+ const pickWinner = () => {
10584
+ let winnerEntry = null;
10585
+ let winnerPubkey = '';
10586
+ for (const [pubkey, entry] of authors) {
10587
+ if (!winnerEntry
10588
+ || entry.firstSeen < winnerEntry.firstSeen
10589
+ || (entry.firstSeen === winnerEntry.firstSeen && pubkey < winnerPubkey)) {
10590
+ winnerEntry = entry;
10591
+ winnerPubkey = pubkey;
10592
+ }
10593
+ }
10594
+ return winnerEntry ? extractResult(winnerEntry.latestEvent) : null;
10595
+ };
10294
10596
  subscriptionId = this.subscribe(filter, {
10295
10597
  onEvent: (event) => {
10296
10598
  // Verify signature to prevent relay injection of forged events (#4)
@@ -10309,24 +10611,55 @@ class NostrClient {
10309
10611
  }
10310
10612
  }
10311
10613
  },
10312
- onEndOfStoredEvents: () => {
10313
- clearTimeout(timeoutId);
10314
- this.unsubscribe(subscriptionId);
10315
- let winnerEntry = null;
10316
- let winnerPubkey = '';
10317
- for (const [pubkey, entry] of authors) {
10318
- if (!winnerEntry
10319
- || entry.firstSeen < winnerEntry.firstSeen
10320
- || (entry.firstSeen === winnerEntry.firstSeen && pubkey < winnerPubkey)) {
10321
- winnerEntry = entry;
10322
- winnerPubkey = pubkey;
10323
- }
10614
+ // EOSE means *this relay* has finished delivering stored
10615
+ // events. In a multi-relay client we must not settle yet — a
10616
+ // slower relay may still be about to deliver matching events.
10617
+ // Settle only when every connected relay has either EOSE'd
10618
+ // OR CLOSED'd this sub. (Single-relay clients are unaffected:
10619
+ // allDone is trivially true with one relay.)
10620
+ onEndOfStoredEvents: (id) => {
10621
+ if (allRelaysDone(id)) {
10622
+ finishWith(pickWinner(), id);
10623
+ }
10624
+ },
10625
+ // Subscription error from the SDK — fires from three paths
10626
+ // that all need the same "is it time to settle?" check:
10627
+ // 1. Relay sent CLOSED for this sub. In a multi-relay
10628
+ // client the same sub_id may still be alive on a
10629
+ // healthy relay; settling on the first CLOSED would
10630
+ // prematurely abort a query other relays could
10631
+ // satisfy. handleClosedMessage records the rejection
10632
+ // on the sending relay's closedSubIds before invoking
10633
+ // us, so we can decide via allRelaysDoneFor.
10634
+ // 2. Relay disconnected mid-query (socket.onclose →
10635
+ // synthetic onError). The relay no longer counts as
10636
+ // connected, so allRelaysDoneFor excludes it.
10637
+ // 3. Client disconnected (disconnect() → synthetic
10638
+ // onError). All relays are torn down, allRelaysDoneFor
10639
+ // sees zero connected and settles.
10640
+ onError: (id, message) => {
10641
+ console.warn(`Subscription error on ${id}: ${message}`);
10642
+ if (allRelaysDone(id)) {
10643
+ finishWith(pickWinner(), id);
10324
10644
  }
10325
- resolve(winnerEntry ? extractResult(winnerEntry.latestEvent) : null);
10645
+ // else: keep waiting for EOSE / CLOSED from remaining
10646
+ // relays or the overall query timeout.
10326
10647
  },
10327
10648
  });
10328
10649
  });
10329
10650
  }
10651
+ /**
10652
+ * True if every currently-connected relay has finished delivering
10653
+ * for the given sub_id (either EOSE'd or CLOSED'd it). Used by
10654
+ * queryWithFirstSeenWins to coordinate multi-relay settlement.
10655
+ */
10656
+ allRelaysDoneFor(subscriptionId) {
10657
+ const connected = Array.from(this.relays.values()).filter((r) => r.connected);
10658
+ // No connected relays at all → nothing to wait for; settle.
10659
+ if (connected.length === 0)
10660
+ return true;
10661
+ return connected.every((r) => r.eosedSubIds.has(subscriptionId) || r.closedSubIds.has(subscriptionId));
10662
+ }
10330
10663
  /**
10331
10664
  * Query for a public key by nametag.
10332
10665
  * Uses first-seen-wins anti-hijacking resolution.