@unicitylabs/nostr-js-sdk 0.2.3 → 0.2.5

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/README.md CHANGED
@@ -44,16 +44,28 @@ console.log(keyManager.getPublicKeyHex());
44
44
  ### Connecting to Relays
45
45
 
46
46
  ```typescript
47
- import { NostrClient, NostrKeyManager } from '@unicitylabs/nostr-sdk';
47
+ import { NostrClient, NostrKeyManager, ConnectionEventListener } from '@unicitylabs/nostr-sdk';
48
48
 
49
49
  const keyManager = NostrKeyManager.generate();
50
50
 
51
- // Create client with default options
51
+ // Create client with default options (auto-reconnect enabled)
52
52
  const client = new NostrClient(keyManager);
53
53
 
54
54
  // Or configure with custom options
55
55
  const client = new NostrClient(keyManager, {
56
- queryTimeoutMs: 15000, // Increase timeout for slow relays (default: 5000ms)
56
+ queryTimeoutMs: 15000, // Query timeout (default: 5000ms)
57
+ autoReconnect: true, // Auto-reconnect on connection loss (default: true)
58
+ reconnectIntervalMs: 1000, // Initial reconnect delay (default: 1000ms)
59
+ maxReconnectIntervalMs: 30000, // Max backoff interval (default: 30000ms)
60
+ pingIntervalMs: 30000, // Health check interval (default: 30000ms, 0 to disable)
61
+ });
62
+
63
+ // Monitor connection events
64
+ client.addConnectionListener({
65
+ onConnect: (url) => console.log(`Connected to ${url}`),
66
+ onDisconnect: (url, reason) => console.log(`Disconnected from ${url}: ${reason}`),
67
+ onReconnecting: (url, attempt) => console.log(`Reconnecting to ${url} (attempt ${attempt})...`),
68
+ onReconnected: (url) => console.log(`Reconnected to ${url}`),
57
69
  });
58
70
 
59
71
  // Connect to relays
@@ -66,7 +78,7 @@ await client.connect(
66
78
  console.log(client.isConnected());
67
79
  console.log(client.getConnectedRelays());
68
80
 
69
- // You can also adjust timeout dynamically
81
+ // Adjust timeout dynamically
70
82
  client.setQueryTimeout(30000); // 30 seconds
71
83
  ```
72
84
 
@@ -5988,10 +5988,11 @@ var nip17 = /*#__PURE__*/Object.freeze({
5988
5988
  */
5989
5989
  /** Connection timeout in milliseconds */
5990
5990
  const CONNECTION_TIMEOUT_MS = 30000;
5991
- /** Reconnection delay in milliseconds */
5992
- const RECONNECT_DELAY_MS = 5000;
5993
- /** Default query timeout in milliseconds */
5991
+ /** Default options */
5994
5992
  const DEFAULT_QUERY_TIMEOUT_MS = 5000;
5993
+ const DEFAULT_RECONNECT_INTERVAL_MS = 1000;
5994
+ const DEFAULT_MAX_RECONNECT_INTERVAL_MS = 30000;
5995
+ const DEFAULT_PING_INTERVAL_MS = 30000;
5995
5996
  /**
5996
5997
  * NostrClient provides the main interface for Nostr protocol operations.
5997
5998
  */
@@ -6003,7 +6004,14 @@ class NostrClient {
6003
6004
  pendingOks = new Map();
6004
6005
  subscriptionCounter = 0;
6005
6006
  closed = false;
6007
+ // Configuration options
6006
6008
  queryTimeoutMs;
6009
+ autoReconnect;
6010
+ reconnectIntervalMs;
6011
+ maxReconnectIntervalMs;
6012
+ pingIntervalMs;
6013
+ // Connection event listeners
6014
+ connectionListeners = [];
6007
6015
  /**
6008
6016
  * Create a NostrClient instance.
6009
6017
  * @param keyManager Key manager with signing keys
@@ -6012,6 +6020,53 @@ class NostrClient {
6012
6020
  constructor(keyManager, options) {
6013
6021
  this.keyManager = keyManager;
6014
6022
  this.queryTimeoutMs = options?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS;
6023
+ this.autoReconnect = options?.autoReconnect ?? true;
6024
+ this.reconnectIntervalMs = options?.reconnectIntervalMs ?? DEFAULT_RECONNECT_INTERVAL_MS;
6025
+ this.maxReconnectIntervalMs = options?.maxReconnectIntervalMs ?? DEFAULT_MAX_RECONNECT_INTERVAL_MS;
6026
+ this.pingIntervalMs = options?.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
6027
+ }
6028
+ /**
6029
+ * Add a connection event listener.
6030
+ * @param listener Listener for connection events
6031
+ */
6032
+ addConnectionListener(listener) {
6033
+ this.connectionListeners.push(listener);
6034
+ }
6035
+ /**
6036
+ * Remove a connection event listener.
6037
+ * @param listener Listener to remove
6038
+ */
6039
+ removeConnectionListener(listener) {
6040
+ const index = this.connectionListeners.indexOf(listener);
6041
+ if (index !== -1) {
6042
+ this.connectionListeners.splice(index, 1);
6043
+ }
6044
+ }
6045
+ /**
6046
+ * Emit a connection event to all listeners.
6047
+ */
6048
+ emitConnectionEvent(eventType, relayUrl, extra) {
6049
+ for (const listener of this.connectionListeners) {
6050
+ try {
6051
+ switch (eventType) {
6052
+ case 'connect':
6053
+ listener.onConnect?.(relayUrl);
6054
+ break;
6055
+ case 'disconnect':
6056
+ listener.onDisconnect?.(relayUrl, extra);
6057
+ break;
6058
+ case 'reconnecting':
6059
+ listener.onReconnecting?.(relayUrl, extra);
6060
+ break;
6061
+ case 'reconnected':
6062
+ listener.onReconnected?.(relayUrl);
6063
+ break;
6064
+ }
6065
+ }
6066
+ catch {
6067
+ // Ignore listener errors
6068
+ }
6069
+ }
6015
6070
  }
6016
6071
  /**
6017
6072
  * Get the key manager.
@@ -6048,13 +6103,12 @@ class NostrClient {
6048
6103
  }
6049
6104
  /**
6050
6105
  * Connect to a single relay.
6106
+ * @param isReconnect Whether this is a reconnection attempt
6051
6107
  */
6052
- async connectToRelay(url) {
6053
- if (this.relays.has(url)) {
6054
- const relay = this.relays.get(url);
6055
- if (relay.connected) {
6056
- return;
6057
- }
6108
+ async connectToRelay(url, isReconnect = false) {
6109
+ const existingRelay = this.relays.get(url);
6110
+ if (existingRelay?.connected) {
6111
+ return;
6058
6112
  }
6059
6113
  return new Promise((resolve, reject) => {
6060
6114
  const timeoutId = setTimeout(() => {
@@ -6067,11 +6121,28 @@ class NostrClient {
6067
6121
  socket,
6068
6122
  connected: false,
6069
6123
  reconnecting: false,
6124
+ reconnectAttempts: 0,
6125
+ reconnectTimer: null,
6126
+ pingTimer: null,
6127
+ lastPongTime: Date.now(),
6128
+ wasConnected: existingRelay?.wasConnected ?? false,
6070
6129
  };
6071
6130
  socket.onopen = () => {
6072
6131
  clearTimeout(timeoutId);
6073
6132
  relay.connected = true;
6133
+ relay.reconnectAttempts = 0; // Reset on successful connection
6134
+ relay.lastPongTime = Date.now();
6074
6135
  this.relays.set(url, relay);
6136
+ // Emit appropriate connection event
6137
+ if (isReconnect && relay.wasConnected) {
6138
+ this.emitConnectionEvent('reconnected', url);
6139
+ }
6140
+ else {
6141
+ this.emitConnectionEvent('connect', url);
6142
+ }
6143
+ relay.wasConnected = true;
6144
+ // Start ping health check
6145
+ this.startPingTimer(url);
6075
6146
  // Re-establish subscriptions
6076
6147
  this.resubscribeAll(url);
6077
6148
  // Flush queued events
@@ -6081,15 +6152,26 @@ class NostrClient {
6081
6152
  socket.onmessage = (event) => {
6082
6153
  try {
6083
6154
  const data = extractMessageData(event);
6155
+ // Update last pong time on any message (relay is alive)
6156
+ const r = this.relays.get(url);
6157
+ if (r) {
6158
+ r.lastPongTime = Date.now();
6159
+ }
6084
6160
  this.handleRelayMessage(url, data);
6085
6161
  }
6086
6162
  catch (error) {
6087
6163
  console.error(`Error handling message from ${url}:`, error);
6088
6164
  }
6089
6165
  };
6090
- socket.onclose = () => {
6166
+ socket.onclose = (event) => {
6167
+ const wasConnected = relay.connected;
6091
6168
  relay.connected = false;
6092
- if (!this.closed && !relay.reconnecting) {
6169
+ this.stopPingTimer(url);
6170
+ if (wasConnected) {
6171
+ const reason = event?.reason || 'Connection closed';
6172
+ this.emitConnectionEvent('disconnect', url, reason);
6173
+ }
6174
+ if (!this.closed && this.autoReconnect && !relay.reconnecting) {
6093
6175
  this.scheduleReconnect(url);
6094
6176
  }
6095
6177
  };
@@ -6108,24 +6190,103 @@ class NostrClient {
6108
6190
  });
6109
6191
  }
6110
6192
  /**
6111
- * Schedule a reconnection attempt for a relay.
6193
+ * Schedule a reconnection attempt for a relay with exponential backoff.
6112
6194
  */
6113
6195
  scheduleReconnect(url) {
6114
6196
  const relay = this.relays.get(url);
6115
- if (!relay || this.closed)
6197
+ if (!relay || this.closed || !this.autoReconnect)
6116
6198
  return;
6199
+ // Clear any existing reconnect timer
6200
+ if (relay.reconnectTimer) {
6201
+ clearTimeout(relay.reconnectTimer);
6202
+ }
6117
6203
  relay.reconnecting = true;
6118
- setTimeout(async () => {
6204
+ relay.reconnectAttempts++;
6205
+ // Calculate delay with exponential backoff
6206
+ const baseDelay = this.reconnectIntervalMs;
6207
+ const exponentialDelay = baseDelay * Math.pow(2, relay.reconnectAttempts - 1);
6208
+ const delay = Math.min(exponentialDelay, this.maxReconnectIntervalMs);
6209
+ this.emitConnectionEvent('reconnecting', url, relay.reconnectAttempts);
6210
+ relay.reconnectTimer = setTimeout(async () => {
6119
6211
  if (this.closed)
6120
6212
  return;
6213
+ relay.reconnectTimer = null;
6121
6214
  try {
6122
6215
  relay.reconnecting = false;
6123
- await this.connectToRelay(url);
6216
+ await this.connectToRelay(url, true);
6217
+ }
6218
+ catch {
6219
+ // Connection failed, schedule another attempt
6220
+ if (!this.closed && this.autoReconnect) {
6221
+ this.scheduleReconnect(url);
6222
+ }
6223
+ }
6224
+ }, delay);
6225
+ }
6226
+ /**
6227
+ * Start the ping timer for a relay to detect stale connections.
6228
+ */
6229
+ startPingTimer(url) {
6230
+ if (this.pingIntervalMs <= 0)
6231
+ return;
6232
+ const relay = this.relays.get(url);
6233
+ if (!relay)
6234
+ return;
6235
+ // Stop existing timer if any
6236
+ this.stopPingTimer(url);
6237
+ relay.pingTimer = setInterval(() => {
6238
+ if (!relay.connected || !relay.socket) {
6239
+ this.stopPingTimer(url);
6240
+ return;
6241
+ }
6242
+ // Check if we've received any message recently
6243
+ const timeSinceLastPong = Date.now() - relay.lastPongTime;
6244
+ if (timeSinceLastPong > this.pingIntervalMs * 2) {
6245
+ // Connection is stale - force close and reconnect
6246
+ console.warn(`Relay ${url} appears stale (no response for ${timeSinceLastPong}ms), reconnecting...`);
6247
+ this.stopPingTimer(url);
6248
+ try {
6249
+ relay.socket.close();
6250
+ }
6251
+ catch {
6252
+ // Ignore close errors
6253
+ }
6254
+ return;
6255
+ }
6256
+ // Send a subscription request as a ping (relays respond with EOSE)
6257
+ // Use a single fixed subscription ID per relay to avoid accumulating subscriptions
6258
+ // Note: limit:1 is used because some relays don't respond to limit:0
6259
+ try {
6260
+ const pingSubId = `ping`;
6261
+ // First close any existing ping subscription to ensure we don't accumulate
6262
+ const closeMessage = JSON.stringify(['CLOSE', pingSubId]);
6263
+ relay.socket.send(closeMessage);
6264
+ // Then send the new ping request (limit:1 ensures relay sends EOSE)
6265
+ const pingMessage = JSON.stringify(['REQ', pingSubId, { limit: 1 }]);
6266
+ relay.socket.send(pingMessage);
6124
6267
  }
6125
6268
  catch {
6126
- // Will trigger another reconnect via onclose
6269
+ // Send failed, connection likely dead
6270
+ console.warn(`Ping to ${url} failed, reconnecting...`);
6271
+ this.stopPingTimer(url);
6272
+ try {
6273
+ relay.socket.close();
6274
+ }
6275
+ catch {
6276
+ // Ignore close errors
6277
+ }
6127
6278
  }
6128
- }, RECONNECT_DELAY_MS);
6279
+ }, this.pingIntervalMs);
6280
+ }
6281
+ /**
6282
+ * Stop the ping timer for a relay.
6283
+ */
6284
+ stopPingTimer(url) {
6285
+ const relay = this.relays.get(url);
6286
+ if (relay?.pingTimer) {
6287
+ clearInterval(relay.pingTimer);
6288
+ relay.pingTimer = null;
6289
+ }
6129
6290
  }
6130
6291
  /**
6131
6292
  * Re-establish all subscriptions for a relay.
@@ -6272,11 +6433,23 @@ class NostrClient {
6272
6433
  item.reject(new Error('Client disconnected'));
6273
6434
  }
6274
6435
  this.eventQueue = [];
6275
- // Close all relay connections
6276
- for (const [, relay] of this.relays) {
6436
+ // Close all relay connections and clean up timers
6437
+ for (const [url, relay] of this.relays) {
6438
+ // Stop ping timer
6439
+ if (relay.pingTimer) {
6440
+ clearInterval(relay.pingTimer);
6441
+ relay.pingTimer = null;
6442
+ }
6443
+ // Stop reconnect timer
6444
+ if (relay.reconnectTimer) {
6445
+ clearTimeout(relay.reconnectTimer);
6446
+ relay.reconnectTimer = null;
6447
+ }
6448
+ // Close socket
6277
6449
  if (relay.socket && relay.socket.readyState !== CLOSED) {
6278
6450
  relay.socket.close(1000, 'Client disconnected');
6279
6451
  }
6452
+ this.emitConnectionEvent('disconnect', url, 'Client disconnected');
6280
6453
  }
6281
6454
  this.relays.clear();
6282
6455
  this.subscriptions.clear();