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