@unicitylabs/sphere-sdk 0.7.1-dev.1 → 0.7.1-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.
@@ -1445,6 +1445,17 @@ var NostrTransportProvider = class _NostrTransportProvider {
1445
1445
  getStorageAdapter() {
1446
1446
  return this.storage;
1447
1447
  }
1448
+ /**
1449
+ * Get the underlying NostrClient (or null if not yet connected).
1450
+ *
1451
+ * Exposed so {@link MultiAddressTransportMux} can share the same
1452
+ * client/socket pair instead of opening a duplicate WebSocket per
1453
+ * relay (#123). The transport owns the client's lifecycle — callers
1454
+ * MUST NOT call {@code disconnect()} on the returned instance.
1455
+ */
1456
+ getNostrClient() {
1457
+ return this.nostrClient;
1458
+ }
1448
1459
  /**
1449
1460
  * Suppress event subscriptions — unsubscribe wallet/chat filters
1450
1461
  * but keep the connection alive for resolve/identity-binding operations.
@@ -3083,9 +3094,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3083
3094
  chatSubscriptionId = null;
3084
3095
  chatEoseFired = false;
3085
3096
  resubscribeTimer = null;
3086
- lastWalletEventAt = Date.now();
3087
- lastChatEventAt = Date.now();
3088
- healthCheckTimer = null;
3089
3097
  chatEoseHandlers = [];
3090
3098
  // Dedup — bounded to prevent memory leak in long-running sessions.
3091
3099
  // Set preserves insertion order; evict oldest entries when cap is reached.
@@ -3096,6 +3104,19 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3096
3104
  // Identity key for the Mux's NostrClient — relays may filter gift-wrap
3097
3105
  // delivery to the recipient's subscription key.
3098
3106
  identityPrivateKey;
3107
+ // Resolves the shared NostrClient at use-time (the source provider may
3108
+ // create its client lazily, after the Mux is constructed). null means
3109
+ // "no shared client; create our own."
3110
+ sharedNostrClientGetter;
3111
+ // True when this Mux is using a shared NostrClient and therefore must
3112
+ // not call connect()/disconnect() on it.
3113
+ usingSharedClient = false;
3114
+ // Listener registered on the underlying NostrClient. Tracked so we can
3115
+ // remove it on disconnect / rebind — otherwise a long-lived shared
3116
+ // client accumulates listeners across address switches and (worse)
3117
+ // a "disconnected" Mux still sees onReconnected callbacks fire and
3118
+ // re-establish subscriptions it shouldn't have.
3119
+ connectionListener = null;
3099
3120
  constructor(config) {
3100
3121
  this.identityPrivateKey = config.identityPrivateKey;
3101
3122
  this.config = {
@@ -3108,6 +3129,14 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3108
3129
  generateUUID: config.generateUUID ?? defaultUUIDGenerator
3109
3130
  };
3110
3131
  this.storage = config.storage ?? null;
3132
+ if (typeof config.sharedNostrClient === "function") {
3133
+ this.sharedNostrClientGetter = config.sharedNostrClient;
3134
+ } else if (config.sharedNostrClient) {
3135
+ const c = config.sharedNostrClient;
3136
+ this.sharedNostrClientGetter = () => c;
3137
+ } else {
3138
+ this.sharedNostrClientGetter = null;
3139
+ }
3111
3140
  }
3112
3141
  // ===========================================================================
3113
3142
  // Address Management
@@ -3198,53 +3227,49 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3198
3227
  if (this.status === "connected") return;
3199
3228
  this.status = "connecting";
3200
3229
  try {
3201
- if (!this.primaryKeyManager) {
3202
- if (this.identityPrivateKey) {
3203
- this.primaryKeyManager = import_nostr_js_sdk2.NostrKeyManager.fromPrivateKey(
3204
- import_buffer2.Buffer.from(this.identityPrivateKey)
3230
+ const shared = this.sharedNostrClientGetter ? this.sharedNostrClientGetter() : null;
3231
+ if (shared) {
3232
+ if (!shared.isConnected()) {
3233
+ throw new SphereError(
3234
+ "sharedNostrClient is not connected; the Mux cannot share a closed socket",
3235
+ "TRANSPORT_ERROR"
3205
3236
  );
3206
- } else {
3207
- const tempKey = import_buffer2.Buffer.alloc(32);
3208
- crypto.getRandomValues(tempKey);
3209
- this.primaryKeyManager = import_nostr_js_sdk2.NostrKeyManager.fromPrivateKey(tempKey);
3210
3237
  }
3238
+ this.nostrClient = shared;
3239
+ this.usingSharedClient = true;
3240
+ } else {
3241
+ if (!this.primaryKeyManager) {
3242
+ if (this.identityPrivateKey) {
3243
+ this.primaryKeyManager = import_nostr_js_sdk2.NostrKeyManager.fromPrivateKey(
3244
+ import_buffer2.Buffer.from(this.identityPrivateKey)
3245
+ );
3246
+ } else {
3247
+ const tempKey = import_buffer2.Buffer.alloc(32);
3248
+ crypto.getRandomValues(tempKey);
3249
+ this.primaryKeyManager = import_nostr_js_sdk2.NostrKeyManager.fromPrivateKey(tempKey);
3250
+ }
3251
+ }
3252
+ this.nostrClient = new import_nostr_js_sdk2.NostrClient(this.primaryKeyManager, {
3253
+ autoReconnect: this.config.autoReconnect,
3254
+ reconnectIntervalMs: this.config.reconnectDelay,
3255
+ maxReconnectIntervalMs: this.config.reconnectDelay * 16,
3256
+ pingIntervalMs: 15e3
3257
+ });
3211
3258
  }
3212
- this.nostrClient = new import_nostr_js_sdk2.NostrClient(this.primaryKeyManager, {
3213
- autoReconnect: this.config.autoReconnect,
3214
- reconnectIntervalMs: this.config.reconnectDelay,
3215
- maxReconnectIntervalMs: this.config.reconnectDelay * 16,
3216
- pingIntervalMs: 15e3
3217
- });
3218
- this.nostrClient.addConnectionListener({
3219
- onConnect: (url) => {
3220
- logger.debug("Mux", "Connected to relay:", url);
3221
- this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3222
- },
3223
- onDisconnect: (url, reason) => {
3224
- logger.debug("Mux", "Disconnected from relay:", url, "reason:", reason);
3225
- },
3226
- onReconnecting: (url, attempt) => {
3227
- logger.debug("Mux", "Reconnecting to relay:", url, "attempt:", attempt);
3228
- this.emitEvent({ type: "transport:reconnecting", timestamp: Date.now() });
3229
- },
3230
- onReconnected: (url) => {
3231
- logger.debug("Mux", "Reconnected to relay:", url);
3232
- this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3233
- this.updateSubscriptions().catch((err) => {
3234
- logger.error("Mux", "Failed to re-subscribe after reconnect:", err);
3235
- });
3259
+ this.connectionListener = this.buildConnectionListener();
3260
+ this.nostrClient.addConnectionListener(this.connectionListener);
3261
+ if (!this.usingSharedClient) {
3262
+ await Promise.race([
3263
+ this.nostrClient.connect(...this.config.relays),
3264
+ new Promise(
3265
+ (_, reject) => setTimeout(() => reject(new Error(
3266
+ `Transport connection timed out after ${this.config.timeout}ms`
3267
+ )), this.config.timeout)
3268
+ )
3269
+ ]);
3270
+ if (!this.nostrClient.isConnected()) {
3271
+ throw new SphereError("Failed to connect to any relay", "TRANSPORT_ERROR");
3236
3272
  }
3237
- });
3238
- await Promise.race([
3239
- this.nostrClient.connect(...this.config.relays),
3240
- new Promise(
3241
- (_, reject) => setTimeout(() => reject(new Error(
3242
- `Transport connection timed out after ${this.config.timeout}ms`
3243
- )), this.config.timeout)
3244
- )
3245
- ]);
3246
- if (!this.nostrClient.isConnected()) {
3247
- throw new SphereError("Failed to connect to any relay", "TRANSPORT_ERROR");
3248
3273
  }
3249
3274
  this.status = "connected";
3250
3275
  this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
@@ -3253,6 +3278,21 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3253
3278
  }
3254
3279
  } catch (error) {
3255
3280
  this.status = "error";
3281
+ if (this.connectionListener && this.nostrClient) {
3282
+ try {
3283
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3284
+ } catch {
3285
+ }
3286
+ }
3287
+ this.connectionListener = null;
3288
+ if (this.nostrClient && !this.usingSharedClient) {
3289
+ try {
3290
+ this.nostrClient.disconnect();
3291
+ } catch {
3292
+ }
3293
+ }
3294
+ this.nostrClient = null;
3295
+ this.usingSharedClient = false;
3256
3296
  throw error;
3257
3297
  }
3258
3298
  }
@@ -3261,25 +3301,153 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3261
3301
  clearTimeout(this.resubscribeTimer);
3262
3302
  this.resubscribeTimer = null;
3263
3303
  }
3264
- if (this.healthCheckTimer) {
3265
- clearInterval(this.healthCheckTimer);
3266
- this.healthCheckTimer = null;
3267
- }
3268
3304
  if (this.nostrClient) {
3269
- this.nostrClient.disconnect();
3305
+ if (this.walletSubscriptionId) {
3306
+ try {
3307
+ this.nostrClient.unsubscribe(this.walletSubscriptionId);
3308
+ } catch {
3309
+ }
3310
+ }
3311
+ if (this.chatSubscriptionId) {
3312
+ try {
3313
+ this.nostrClient.unsubscribe(this.chatSubscriptionId);
3314
+ } catch {
3315
+ }
3316
+ }
3317
+ if (this.connectionListener) {
3318
+ try {
3319
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3320
+ } catch {
3321
+ }
3322
+ }
3323
+ if (!this.usingSharedClient) {
3324
+ this.nostrClient.disconnect();
3325
+ }
3270
3326
  this.nostrClient = null;
3271
3327
  }
3328
+ this.connectionListener = null;
3329
+ this.usingSharedClient = false;
3272
3330
  this.walletSubscriptionId = null;
3273
3331
  this.chatSubscriptionId = null;
3274
3332
  this.chatEoseFired = false;
3275
- this.lastWalletEventAt = Date.now();
3276
- this.lastChatEventAt = Date.now();
3277
3333
  this.status = "disconnected";
3278
3334
  this.emitEvent({ type: "transport:disconnected", timestamp: Date.now() });
3279
3335
  }
3280
3336
  isConnected() {
3281
3337
  return this.status === "connected" && this.nostrClient?.isConnected() === true;
3282
3338
  }
3339
+ /**
3340
+ * Build the connection listener used by both {@link connect} and
3341
+ * {@link rebindToSharedClient}.
3342
+ *
3343
+ * Behavioral notes:
3344
+ * - When the Mux is sharing a {@link NostrClient} with the host
3345
+ * transport (#123), we deliberately do NOT emit
3346
+ * {@code transport:connected} / {@code transport:reconnecting} here
3347
+ * — the host transport's own listener already emits those for the
3348
+ * same socket event. Re-subscribing after a reconnect IS still our
3349
+ * responsibility, since the host has
3350
+ * {@code suppressSubscriptions()}'d its own filters.
3351
+ * - {@code onConnect} does not emit {@code transport:connected}.
3352
+ * The SDK only fires {@code onConnect} on the initial socket
3353
+ * connection (subsequent reconnects use {@code onReconnected}),
3354
+ * and {@link connect()}'s bottom already emits
3355
+ * {@code transport:connected} once that returns. Emitting here too
3356
+ * would double-fire on every initial connect.
3357
+ * - Each callback bails out early when the Mux is not in an active
3358
+ * state ({@code disconnected} / {@code error}). Listeners are
3359
+ * removed on {@code disconnect()} before the callback can fire,
3360
+ * so this guard is mainly defense-in-depth against any in-flight
3361
+ * callback that lands during teardown — but having it at the top
3362
+ * means we never emit a misleading {@code transport:connected}
3363
+ * from a Mux that has already torn down.
3364
+ */
3365
+ buildConnectionListener() {
3366
+ const isInactive = () => this.status === "disconnected" || this.status === "error";
3367
+ return {
3368
+ onConnect: (url) => {
3369
+ if (isInactive()) return;
3370
+ logger.debug("Mux", "Connected to relay:", url);
3371
+ },
3372
+ onDisconnect: (url, reason) => {
3373
+ logger.debug("Mux", "Disconnected from relay:", url, "reason:", reason);
3374
+ },
3375
+ onReconnecting: (url, attempt) => {
3376
+ if (isInactive()) return;
3377
+ logger.debug("Mux", "Reconnecting to relay:", url, "attempt:", attempt);
3378
+ if (!this.usingSharedClient) {
3379
+ this.emitEvent({ type: "transport:reconnecting", timestamp: Date.now() });
3380
+ }
3381
+ },
3382
+ onReconnected: (url) => {
3383
+ if (isInactive()) return;
3384
+ logger.debug("Mux", "Reconnected to relay:", url);
3385
+ if (!this.usingSharedClient) {
3386
+ this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3387
+ }
3388
+ this.updateSubscriptions().catch((err) => {
3389
+ logger.error("Mux", "Failed to re-subscribe after reconnect:", err);
3390
+ });
3391
+ }
3392
+ };
3393
+ }
3394
+ /**
3395
+ * Re-attach to a freshly-created shared NostrClient.
3396
+ *
3397
+ * Call this after the host (e.g. {@link NostrTransportProvider}) has
3398
+ * recreated its NostrClient — typically because the wallet's active
3399
+ * identity changed and the SDK's NostrClient does not support
3400
+ * changing identity at runtime. The previous client has already
3401
+ * been disconnected by the host, so its server-side subscriptions
3402
+ * are gone — we just adopt the new client and re-issue our own.
3403
+ *
3404
+ * The caller is responsible for ordering: by the time rebind runs,
3405
+ * the host transport's new NostrClient must already be created and
3406
+ * connected. In Sphere this is guaranteed because we await
3407
+ * {@code transport.setIdentity()} before calling rebind.
3408
+ *
3409
+ * Returns silently in two cases that are not caller errors:
3410
+ * - the Mux owns its own client (not sharing) — nothing to rebind
3411
+ * - the shared client reference hasn't changed (rebind is a no-op)
3412
+ *
3413
+ * Throws otherwise (rather than silently no-op'ing) so a wiring
3414
+ * mistake — for instance, calling rebind before the host's new
3415
+ * client is ready — surfaces immediately instead of leaving the
3416
+ * Mux pinned to a stale client.
3417
+ */
3418
+ async rebindToSharedClient() {
3419
+ if (!this.usingSharedClient) return;
3420
+ if (!this.sharedNostrClientGetter) return;
3421
+ const newClient = this.sharedNostrClientGetter();
3422
+ if (!newClient) {
3423
+ throw new SphereError(
3424
+ "rebindToSharedClient: shared client getter returned null. The host transport must finish (re)creating its NostrClient before rebind is called.",
3425
+ "TRANSPORT_ERROR"
3426
+ );
3427
+ }
3428
+ if (this.nostrClient === newClient) return;
3429
+ if (!newClient.isConnected()) {
3430
+ throw new SphereError(
3431
+ "rebindToSharedClient: new shared client is not connected. Await transport.setIdentity() / transport.connect() before rebinding.",
3432
+ "TRANSPORT_ERROR"
3433
+ );
3434
+ }
3435
+ if (this.nostrClient && this.connectionListener && this.nostrClient !== newClient) {
3436
+ try {
3437
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3438
+ } catch {
3439
+ }
3440
+ }
3441
+ this.nostrClient = newClient;
3442
+ this.walletSubscriptionId = null;
3443
+ this.chatSubscriptionId = null;
3444
+ this.chatEoseFired = false;
3445
+ this.connectionListener = this.buildConnectionListener();
3446
+ this.nostrClient.addConnectionListener(this.connectionListener);
3447
+ if (this.addresses.size > 0) {
3448
+ await this.updateSubscriptions();
3449
+ }
3450
+ }
3283
3451
  /**
3284
3452
  * One-shot fetch of pending events from the relay.
3285
3453
  * Creates a temporary subscription, waits for EOSE (or timeout),
@@ -3408,8 +3576,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3408
3576
  this.nostrClient.unsubscribe(this.chatSubscriptionId);
3409
3577
  this.chatSubscriptionId = null;
3410
3578
  }
3411
- this.lastWalletEventAt = Date.now();
3412
- this.lastChatEventAt = Date.now();
3413
3579
  if (this.addresses.size === 0) return;
3414
3580
  const allPubkeys = [];
3415
3581
  for (const entry of this.addresses.values()) {
@@ -3496,25 +3662,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3496
3662
  }
3497
3663
  });
3498
3664
  logger.debug("Mux", `updateSubscriptions: walletSub=${this.walletSubscriptionId} chatSub=${this.chatSubscriptionId}`);
3499
- this.startHealthCheck();
3500
- }
3501
- startHealthCheck() {
3502
- if (this.healthCheckTimer) return;
3503
- this.healthCheckTimer = setInterval(() => {
3504
- if (!this.isConnected()) return;
3505
- const chatElapsed = Date.now() - this.lastChatEventAt;
3506
- const walletElapsed = Date.now() - this.lastWalletEventAt;
3507
- const needResubscribe = chatElapsed > 6e4 || walletElapsed > 3e5;
3508
- if (needResubscribe) {
3509
- const reason = chatElapsed > 6e4 ? `No chat events for ${Math.round(chatElapsed / 1e3)}s` : `No wallet events for ${Math.round(walletElapsed / 1e3)}s`;
3510
- logger.warn("Mux", `${reason} \u2014 re-subscribing`);
3511
- this.lastChatEventAt = Date.now();
3512
- this.lastWalletEventAt = Date.now();
3513
- this.updateSubscriptions().catch((err) => {
3514
- logger.warn("Mux", "Health check re-subscription failed:", err);
3515
- });
3516
- }
3517
- }, 3e4);
3518
3665
  }
3519
3666
  /**
3520
3667
  * Schedule a re-subscription after a relay-initiated subscription closure.
@@ -3575,12 +3722,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3575
3722
  }
3576
3723
  }
3577
3724
  }
3578
- if (event.kind !== import_nostr_js_sdk2.EventKinds.GIFT_WRAP) {
3579
- this.lastWalletEventAt = Date.now();
3580
- }
3581
- if (event.kind === import_nostr_js_sdk2.EventKinds.GIFT_WRAP) {
3582
- this.lastChatEventAt = Date.now();
3583
- }
3584
3725
  try {
3585
3726
  if (event.kind === import_nostr_js_sdk2.EventKinds.GIFT_WRAP) {
3586
3727
  await this.routeGiftWrap(event);
@@ -28295,6 +28436,9 @@ var Sphere = class _Sphere {
28295
28436
  this._transport.setFallbackSince(fallbackTs);
28296
28437
  }
28297
28438
  await this._transport.setIdentity(this._identity);
28439
+ if (this._transportMux && typeof this._transportMux.rebindToSharedClient === "function") {
28440
+ await this._transportMux.rebindToSharedClient();
28441
+ }
28298
28442
  this.emitEvent("identity:changed", {
28299
28443
  l1Address: this._identity.l1Address,
28300
28444
  directAddress: this._identity.directAddress,
@@ -28505,7 +28649,12 @@ var Sphere = class _Sphere {
28505
28649
  this._transportMux = new MultiAddressTransportMux({
28506
28650
  relays: nostrTransport.getConfiguredRelays(),
28507
28651
  createWebSocket: nostrTransport.getWebSocketFactory(),
28508
- storage: nostrTransport.getStorageAdapter() ?? void 0
28652
+ storage: nostrTransport.getStorageAdapter() ?? void 0,
28653
+ // #123: share the original transport's NostrClient instead of
28654
+ // opening a second WebSocket per relay. Pass a getter so the
28655
+ // Mux resolves it at connect-time (after the transport finishes
28656
+ // its own connect()).
28657
+ sharedNostrClient: typeof nostrTransport.getNostrClient === "function" ? () => nostrTransport.getNostrClient() : void 0
28509
28658
  });
28510
28659
  await this._transportMux.connect();
28511
28660
  if (typeof nostrTransport.suppressSubscriptions === "function") {