@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.
- package/dist/browser/index.js +383 -50
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/index.min.js +6 -6
- package/dist/browser/index.min.js.map +1 -1
- package/dist/browser/index.umd.js +383 -50
- package/dist/browser/index.umd.js.map +1 -1
- package/dist/browser/index.umd.min.js +7 -7
- package/dist/browser/index.umd.min.js.map +1 -1
- package/dist/cjs/client/NostrClient.js +383 -50
- package/dist/cjs/client/NostrClient.js.map +1 -1
- package/dist/esm/client/NostrClient.js +383 -50
- package/dist/esm/client/NostrClient.js.map +1 -1
- package/dist/types/client/NostrClient.d.ts +46 -5
- package/dist/types/client/NostrClient.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
|
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
|
-
//
|
|
9817
|
-
//
|
|
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',
|
|
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([
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
10295
|
-
|
|
10296
|
-
|
|
10297
|
-
|
|
10298
|
-
|
|
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
|
-
|
|
10319
|
-
|
|
10320
|
-
|
|
10321
|
-
|
|
10322
|
-
|
|
10323
|
-
|
|
10324
|
-
|
|
10325
|
-
|
|
10326
|
-
|
|
10327
|
-
|
|
10328
|
-
|
|
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
|
-
|
|
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.
|