@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.
@@ -5994,10 +5994,11 @@
5994
5994
  */
5995
5995
  /** Connection timeout in milliseconds */
5996
5996
  const CONNECTION_TIMEOUT_MS = 30000;
5997
- /** Reconnection delay in milliseconds */
5998
- const RECONNECT_DELAY_MS = 5000;
5999
- /** Default query timeout in milliseconds */
5997
+ /** Default options */
6000
5998
  const DEFAULT_QUERY_TIMEOUT_MS = 5000;
5999
+ const DEFAULT_RECONNECT_INTERVAL_MS = 1000;
6000
+ const DEFAULT_MAX_RECONNECT_INTERVAL_MS = 30000;
6001
+ const DEFAULT_PING_INTERVAL_MS = 30000;
6001
6002
  /**
6002
6003
  * NostrClient provides the main interface for Nostr protocol operations.
6003
6004
  */
@@ -6009,7 +6010,14 @@
6009
6010
  pendingOks = new Map();
6010
6011
  subscriptionCounter = 0;
6011
6012
  closed = false;
6013
+ // Configuration options
6012
6014
  queryTimeoutMs;
6015
+ autoReconnect;
6016
+ reconnectIntervalMs;
6017
+ maxReconnectIntervalMs;
6018
+ pingIntervalMs;
6019
+ // Connection event listeners
6020
+ connectionListeners = [];
6013
6021
  /**
6014
6022
  * Create a NostrClient instance.
6015
6023
  * @param keyManager Key manager with signing keys
@@ -6018,6 +6026,53 @@
6018
6026
  constructor(keyManager, options) {
6019
6027
  this.keyManager = keyManager;
6020
6028
  this.queryTimeoutMs = options?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS;
6029
+ this.autoReconnect = options?.autoReconnect ?? true;
6030
+ this.reconnectIntervalMs = options?.reconnectIntervalMs ?? DEFAULT_RECONNECT_INTERVAL_MS;
6031
+ this.maxReconnectIntervalMs = options?.maxReconnectIntervalMs ?? DEFAULT_MAX_RECONNECT_INTERVAL_MS;
6032
+ this.pingIntervalMs = options?.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
6033
+ }
6034
+ /**
6035
+ * Add a connection event listener.
6036
+ * @param listener Listener for connection events
6037
+ */
6038
+ addConnectionListener(listener) {
6039
+ this.connectionListeners.push(listener);
6040
+ }
6041
+ /**
6042
+ * Remove a connection event listener.
6043
+ * @param listener Listener to remove
6044
+ */
6045
+ removeConnectionListener(listener) {
6046
+ const index = this.connectionListeners.indexOf(listener);
6047
+ if (index !== -1) {
6048
+ this.connectionListeners.splice(index, 1);
6049
+ }
6050
+ }
6051
+ /**
6052
+ * Emit a connection event to all listeners.
6053
+ */
6054
+ emitConnectionEvent(eventType, relayUrl, extra) {
6055
+ for (const listener of this.connectionListeners) {
6056
+ try {
6057
+ switch (eventType) {
6058
+ case 'connect':
6059
+ listener.onConnect?.(relayUrl);
6060
+ break;
6061
+ case 'disconnect':
6062
+ listener.onDisconnect?.(relayUrl, extra);
6063
+ break;
6064
+ case 'reconnecting':
6065
+ listener.onReconnecting?.(relayUrl, extra);
6066
+ break;
6067
+ case 'reconnected':
6068
+ listener.onReconnected?.(relayUrl);
6069
+ break;
6070
+ }
6071
+ }
6072
+ catch {
6073
+ // Ignore listener errors
6074
+ }
6075
+ }
6021
6076
  }
6022
6077
  /**
6023
6078
  * Get the key manager.
@@ -6054,13 +6109,12 @@
6054
6109
  }
6055
6110
  /**
6056
6111
  * Connect to a single relay.
6112
+ * @param isReconnect Whether this is a reconnection attempt
6057
6113
  */
6058
- async connectToRelay(url) {
6059
- if (this.relays.has(url)) {
6060
- const relay = this.relays.get(url);
6061
- if (relay.connected) {
6062
- return;
6063
- }
6114
+ async connectToRelay(url, isReconnect = false) {
6115
+ const existingRelay = this.relays.get(url);
6116
+ if (existingRelay?.connected) {
6117
+ return;
6064
6118
  }
6065
6119
  return new Promise((resolve, reject) => {
6066
6120
  const timeoutId = setTimeout(() => {
@@ -6073,11 +6127,28 @@
6073
6127
  socket,
6074
6128
  connected: false,
6075
6129
  reconnecting: false,
6130
+ reconnectAttempts: 0,
6131
+ reconnectTimer: null,
6132
+ pingTimer: null,
6133
+ lastPongTime: Date.now(),
6134
+ wasConnected: existingRelay?.wasConnected ?? false,
6076
6135
  };
6077
6136
  socket.onopen = () => {
6078
6137
  clearTimeout(timeoutId);
6079
6138
  relay.connected = true;
6139
+ relay.reconnectAttempts = 0; // Reset on successful connection
6140
+ relay.lastPongTime = Date.now();
6080
6141
  this.relays.set(url, relay);
6142
+ // Emit appropriate connection event
6143
+ if (isReconnect && relay.wasConnected) {
6144
+ this.emitConnectionEvent('reconnected', url);
6145
+ }
6146
+ else {
6147
+ this.emitConnectionEvent('connect', url);
6148
+ }
6149
+ relay.wasConnected = true;
6150
+ // Start ping health check
6151
+ this.startPingTimer(url);
6081
6152
  // Re-establish subscriptions
6082
6153
  this.resubscribeAll(url);
6083
6154
  // Flush queued events
@@ -6087,15 +6158,26 @@
6087
6158
  socket.onmessage = (event) => {
6088
6159
  try {
6089
6160
  const data = extractMessageData(event);
6161
+ // Update last pong time on any message (relay is alive)
6162
+ const r = this.relays.get(url);
6163
+ if (r) {
6164
+ r.lastPongTime = Date.now();
6165
+ }
6090
6166
  this.handleRelayMessage(url, data);
6091
6167
  }
6092
6168
  catch (error) {
6093
6169
  console.error(`Error handling message from ${url}:`, error);
6094
6170
  }
6095
6171
  };
6096
- socket.onclose = () => {
6172
+ socket.onclose = (event) => {
6173
+ const wasConnected = relay.connected;
6097
6174
  relay.connected = false;
6098
- if (!this.closed && !relay.reconnecting) {
6175
+ this.stopPingTimer(url);
6176
+ if (wasConnected) {
6177
+ const reason = event?.reason || 'Connection closed';
6178
+ this.emitConnectionEvent('disconnect', url, reason);
6179
+ }
6180
+ if (!this.closed && this.autoReconnect && !relay.reconnecting) {
6099
6181
  this.scheduleReconnect(url);
6100
6182
  }
6101
6183
  };
@@ -6114,24 +6196,103 @@
6114
6196
  });
6115
6197
  }
6116
6198
  /**
6117
- * Schedule a reconnection attempt for a relay.
6199
+ * Schedule a reconnection attempt for a relay with exponential backoff.
6118
6200
  */
6119
6201
  scheduleReconnect(url) {
6120
6202
  const relay = this.relays.get(url);
6121
- if (!relay || this.closed)
6203
+ if (!relay || this.closed || !this.autoReconnect)
6122
6204
  return;
6205
+ // Clear any existing reconnect timer
6206
+ if (relay.reconnectTimer) {
6207
+ clearTimeout(relay.reconnectTimer);
6208
+ }
6123
6209
  relay.reconnecting = true;
6124
- setTimeout(async () => {
6210
+ relay.reconnectAttempts++;
6211
+ // Calculate delay with exponential backoff
6212
+ const baseDelay = this.reconnectIntervalMs;
6213
+ const exponentialDelay = baseDelay * Math.pow(2, relay.reconnectAttempts - 1);
6214
+ const delay = Math.min(exponentialDelay, this.maxReconnectIntervalMs);
6215
+ this.emitConnectionEvent('reconnecting', url, relay.reconnectAttempts);
6216
+ relay.reconnectTimer = setTimeout(async () => {
6125
6217
  if (this.closed)
6126
6218
  return;
6219
+ relay.reconnectTimer = null;
6127
6220
  try {
6128
6221
  relay.reconnecting = false;
6129
- await this.connectToRelay(url);
6222
+ await this.connectToRelay(url, true);
6223
+ }
6224
+ catch {
6225
+ // Connection failed, schedule another attempt
6226
+ if (!this.closed && this.autoReconnect) {
6227
+ this.scheduleReconnect(url);
6228
+ }
6229
+ }
6230
+ }, delay);
6231
+ }
6232
+ /**
6233
+ * Start the ping timer for a relay to detect stale connections.
6234
+ */
6235
+ startPingTimer(url) {
6236
+ if (this.pingIntervalMs <= 0)
6237
+ return;
6238
+ const relay = this.relays.get(url);
6239
+ if (!relay)
6240
+ return;
6241
+ // Stop existing timer if any
6242
+ this.stopPingTimer(url);
6243
+ relay.pingTimer = setInterval(() => {
6244
+ if (!relay.connected || !relay.socket) {
6245
+ this.stopPingTimer(url);
6246
+ return;
6247
+ }
6248
+ // Check if we've received any message recently
6249
+ const timeSinceLastPong = Date.now() - relay.lastPongTime;
6250
+ if (timeSinceLastPong > this.pingIntervalMs * 2) {
6251
+ // Connection is stale - force close and reconnect
6252
+ console.warn(`Relay ${url} appears stale (no response for ${timeSinceLastPong}ms), reconnecting...`);
6253
+ this.stopPingTimer(url);
6254
+ try {
6255
+ relay.socket.close();
6256
+ }
6257
+ catch {
6258
+ // Ignore close errors
6259
+ }
6260
+ return;
6261
+ }
6262
+ // Send a subscription request as a ping (relays respond with EOSE)
6263
+ // Use a single fixed subscription ID per relay to avoid accumulating subscriptions
6264
+ // Note: limit:1 is used because some relays don't respond to limit:0
6265
+ try {
6266
+ const pingSubId = `ping`;
6267
+ // First close any existing ping subscription to ensure we don't accumulate
6268
+ const closeMessage = JSON.stringify(['CLOSE', pingSubId]);
6269
+ relay.socket.send(closeMessage);
6270
+ // Then send the new ping request (limit:1 ensures relay sends EOSE)
6271
+ const pingMessage = JSON.stringify(['REQ', pingSubId, { limit: 1 }]);
6272
+ relay.socket.send(pingMessage);
6130
6273
  }
6131
6274
  catch {
6132
- // Will trigger another reconnect via onclose
6275
+ // Send failed, connection likely dead
6276
+ console.warn(`Ping to ${url} failed, reconnecting...`);
6277
+ this.stopPingTimer(url);
6278
+ try {
6279
+ relay.socket.close();
6280
+ }
6281
+ catch {
6282
+ // Ignore close errors
6283
+ }
6133
6284
  }
6134
- }, RECONNECT_DELAY_MS);
6285
+ }, this.pingIntervalMs);
6286
+ }
6287
+ /**
6288
+ * Stop the ping timer for a relay.
6289
+ */
6290
+ stopPingTimer(url) {
6291
+ const relay = this.relays.get(url);
6292
+ if (relay?.pingTimer) {
6293
+ clearInterval(relay.pingTimer);
6294
+ relay.pingTimer = null;
6295
+ }
6135
6296
  }
6136
6297
  /**
6137
6298
  * Re-establish all subscriptions for a relay.
@@ -6278,11 +6439,23 @@
6278
6439
  item.reject(new Error('Client disconnected'));
6279
6440
  }
6280
6441
  this.eventQueue = [];
6281
- // Close all relay connections
6282
- for (const [, relay] of this.relays) {
6442
+ // Close all relay connections and clean up timers
6443
+ for (const [url, relay] of this.relays) {
6444
+ // Stop ping timer
6445
+ if (relay.pingTimer) {
6446
+ clearInterval(relay.pingTimer);
6447
+ relay.pingTimer = null;
6448
+ }
6449
+ // Stop reconnect timer
6450
+ if (relay.reconnectTimer) {
6451
+ clearTimeout(relay.reconnectTimer);
6452
+ relay.reconnectTimer = null;
6453
+ }
6454
+ // Close socket
6283
6455
  if (relay.socket && relay.socket.readyState !== CLOSED) {
6284
6456
  relay.socket.close(1000, 'Client disconnected');
6285
6457
  }
6458
+ this.emitConnectionEvent('disconnect', url, 'Client disconnected');
6286
6459
  }
6287
6460
  this.relays.clear();
6288
6461
  this.subscriptions.clear();