@unicitylabs/sphere-sdk 0.7.1-dev.2 → 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.
package/dist/index.cjs CHANGED
@@ -1555,6 +1555,17 @@ var NostrTransportProvider = class _NostrTransportProvider {
1555
1555
  getStorageAdapter() {
1556
1556
  return this.storage;
1557
1557
  }
1558
+ /**
1559
+ * Get the underlying NostrClient (or null if not yet connected).
1560
+ *
1561
+ * Exposed so {@link MultiAddressTransportMux} can share the same
1562
+ * client/socket pair instead of opening a duplicate WebSocket per
1563
+ * relay (#123). The transport owns the client's lifecycle — callers
1564
+ * MUST NOT call {@code disconnect()} on the returned instance.
1565
+ */
1566
+ getNostrClient() {
1567
+ return this.nostrClient;
1568
+ }
1558
1569
  /**
1559
1570
  * Suppress event subscriptions — unsubscribe wallet/chat filters
1560
1571
  * but keep the connection alive for resolve/identity-binding operations.
@@ -3193,9 +3204,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3193
3204
  chatSubscriptionId = null;
3194
3205
  chatEoseFired = false;
3195
3206
  resubscribeTimer = null;
3196
- lastWalletEventAt = Date.now();
3197
- lastChatEventAt = Date.now();
3198
- healthCheckTimer = null;
3199
3207
  chatEoseHandlers = [];
3200
3208
  // Dedup — bounded to prevent memory leak in long-running sessions.
3201
3209
  // Set preserves insertion order; evict oldest entries when cap is reached.
@@ -3206,6 +3214,19 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3206
3214
  // Identity key for the Mux's NostrClient — relays may filter gift-wrap
3207
3215
  // delivery to the recipient's subscription key.
3208
3216
  identityPrivateKey;
3217
+ // Resolves the shared NostrClient at use-time (the source provider may
3218
+ // create its client lazily, after the Mux is constructed). null means
3219
+ // "no shared client; create our own."
3220
+ sharedNostrClientGetter;
3221
+ // True when this Mux is using a shared NostrClient and therefore must
3222
+ // not call connect()/disconnect() on it.
3223
+ usingSharedClient = false;
3224
+ // Listener registered on the underlying NostrClient. Tracked so we can
3225
+ // remove it on disconnect / rebind — otherwise a long-lived shared
3226
+ // client accumulates listeners across address switches and (worse)
3227
+ // a "disconnected" Mux still sees onReconnected callbacks fire and
3228
+ // re-establish subscriptions it shouldn't have.
3229
+ connectionListener = null;
3209
3230
  constructor(config) {
3210
3231
  this.identityPrivateKey = config.identityPrivateKey;
3211
3232
  this.config = {
@@ -3218,6 +3239,14 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3218
3239
  generateUUID: config.generateUUID ?? defaultUUIDGenerator
3219
3240
  };
3220
3241
  this.storage = config.storage ?? null;
3242
+ if (typeof config.sharedNostrClient === "function") {
3243
+ this.sharedNostrClientGetter = config.sharedNostrClient;
3244
+ } else if (config.sharedNostrClient) {
3245
+ const c = config.sharedNostrClient;
3246
+ this.sharedNostrClientGetter = () => c;
3247
+ } else {
3248
+ this.sharedNostrClientGetter = null;
3249
+ }
3221
3250
  }
3222
3251
  // ===========================================================================
3223
3252
  // Address Management
@@ -3308,53 +3337,49 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3308
3337
  if (this.status === "connected") return;
3309
3338
  this.status = "connecting";
3310
3339
  try {
3311
- if (!this.primaryKeyManager) {
3312
- if (this.identityPrivateKey) {
3313
- this.primaryKeyManager = import_nostr_js_sdk2.NostrKeyManager.fromPrivateKey(
3314
- import_buffer2.Buffer.from(this.identityPrivateKey)
3340
+ const shared = this.sharedNostrClientGetter ? this.sharedNostrClientGetter() : null;
3341
+ if (shared) {
3342
+ if (!shared.isConnected()) {
3343
+ throw new SphereError(
3344
+ "sharedNostrClient is not connected; the Mux cannot share a closed socket",
3345
+ "TRANSPORT_ERROR"
3315
3346
  );
3316
- } else {
3317
- const tempKey = import_buffer2.Buffer.alloc(32);
3318
- crypto.getRandomValues(tempKey);
3319
- this.primaryKeyManager = import_nostr_js_sdk2.NostrKeyManager.fromPrivateKey(tempKey);
3320
3347
  }
3348
+ this.nostrClient = shared;
3349
+ this.usingSharedClient = true;
3350
+ } else {
3351
+ if (!this.primaryKeyManager) {
3352
+ if (this.identityPrivateKey) {
3353
+ this.primaryKeyManager = import_nostr_js_sdk2.NostrKeyManager.fromPrivateKey(
3354
+ import_buffer2.Buffer.from(this.identityPrivateKey)
3355
+ );
3356
+ } else {
3357
+ const tempKey = import_buffer2.Buffer.alloc(32);
3358
+ crypto.getRandomValues(tempKey);
3359
+ this.primaryKeyManager = import_nostr_js_sdk2.NostrKeyManager.fromPrivateKey(tempKey);
3360
+ }
3361
+ }
3362
+ this.nostrClient = new import_nostr_js_sdk2.NostrClient(this.primaryKeyManager, {
3363
+ autoReconnect: this.config.autoReconnect,
3364
+ reconnectIntervalMs: this.config.reconnectDelay,
3365
+ maxReconnectIntervalMs: this.config.reconnectDelay * 16,
3366
+ pingIntervalMs: 15e3
3367
+ });
3321
3368
  }
3322
- this.nostrClient = new import_nostr_js_sdk2.NostrClient(this.primaryKeyManager, {
3323
- autoReconnect: this.config.autoReconnect,
3324
- reconnectIntervalMs: this.config.reconnectDelay,
3325
- maxReconnectIntervalMs: this.config.reconnectDelay * 16,
3326
- pingIntervalMs: 15e3
3327
- });
3328
- this.nostrClient.addConnectionListener({
3329
- onConnect: (url) => {
3330
- logger.debug("Mux", "Connected to relay:", url);
3331
- this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3332
- },
3333
- onDisconnect: (url, reason) => {
3334
- logger.debug("Mux", "Disconnected from relay:", url, "reason:", reason);
3335
- },
3336
- onReconnecting: (url, attempt) => {
3337
- logger.debug("Mux", "Reconnecting to relay:", url, "attempt:", attempt);
3338
- this.emitEvent({ type: "transport:reconnecting", timestamp: Date.now() });
3339
- },
3340
- onReconnected: (url) => {
3341
- logger.debug("Mux", "Reconnected to relay:", url);
3342
- this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3343
- this.updateSubscriptions().catch((err) => {
3344
- logger.error("Mux", "Failed to re-subscribe after reconnect:", err);
3345
- });
3369
+ this.connectionListener = this.buildConnectionListener();
3370
+ this.nostrClient.addConnectionListener(this.connectionListener);
3371
+ if (!this.usingSharedClient) {
3372
+ await Promise.race([
3373
+ this.nostrClient.connect(...this.config.relays),
3374
+ new Promise(
3375
+ (_, reject) => setTimeout(() => reject(new Error(
3376
+ `Transport connection timed out after ${this.config.timeout}ms`
3377
+ )), this.config.timeout)
3378
+ )
3379
+ ]);
3380
+ if (!this.nostrClient.isConnected()) {
3381
+ throw new SphereError("Failed to connect to any relay", "TRANSPORT_ERROR");
3346
3382
  }
3347
- });
3348
- await Promise.race([
3349
- this.nostrClient.connect(...this.config.relays),
3350
- new Promise(
3351
- (_, reject) => setTimeout(() => reject(new Error(
3352
- `Transport connection timed out after ${this.config.timeout}ms`
3353
- )), this.config.timeout)
3354
- )
3355
- ]);
3356
- if (!this.nostrClient.isConnected()) {
3357
- throw new SphereError("Failed to connect to any relay", "TRANSPORT_ERROR");
3358
3383
  }
3359
3384
  this.status = "connected";
3360
3385
  this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
@@ -3363,6 +3388,21 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3363
3388
  }
3364
3389
  } catch (error) {
3365
3390
  this.status = "error";
3391
+ if (this.connectionListener && this.nostrClient) {
3392
+ try {
3393
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3394
+ } catch {
3395
+ }
3396
+ }
3397
+ this.connectionListener = null;
3398
+ if (this.nostrClient && !this.usingSharedClient) {
3399
+ try {
3400
+ this.nostrClient.disconnect();
3401
+ } catch {
3402
+ }
3403
+ }
3404
+ this.nostrClient = null;
3405
+ this.usingSharedClient = false;
3366
3406
  throw error;
3367
3407
  }
3368
3408
  }
@@ -3371,25 +3411,153 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3371
3411
  clearTimeout(this.resubscribeTimer);
3372
3412
  this.resubscribeTimer = null;
3373
3413
  }
3374
- if (this.healthCheckTimer) {
3375
- clearInterval(this.healthCheckTimer);
3376
- this.healthCheckTimer = null;
3377
- }
3378
3414
  if (this.nostrClient) {
3379
- this.nostrClient.disconnect();
3415
+ if (this.walletSubscriptionId) {
3416
+ try {
3417
+ this.nostrClient.unsubscribe(this.walletSubscriptionId);
3418
+ } catch {
3419
+ }
3420
+ }
3421
+ if (this.chatSubscriptionId) {
3422
+ try {
3423
+ this.nostrClient.unsubscribe(this.chatSubscriptionId);
3424
+ } catch {
3425
+ }
3426
+ }
3427
+ if (this.connectionListener) {
3428
+ try {
3429
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3430
+ } catch {
3431
+ }
3432
+ }
3433
+ if (!this.usingSharedClient) {
3434
+ this.nostrClient.disconnect();
3435
+ }
3380
3436
  this.nostrClient = null;
3381
3437
  }
3438
+ this.connectionListener = null;
3439
+ this.usingSharedClient = false;
3382
3440
  this.walletSubscriptionId = null;
3383
3441
  this.chatSubscriptionId = null;
3384
3442
  this.chatEoseFired = false;
3385
- this.lastWalletEventAt = Date.now();
3386
- this.lastChatEventAt = Date.now();
3387
3443
  this.status = "disconnected";
3388
3444
  this.emitEvent({ type: "transport:disconnected", timestamp: Date.now() });
3389
3445
  }
3390
3446
  isConnected() {
3391
3447
  return this.status === "connected" && this.nostrClient?.isConnected() === true;
3392
3448
  }
3449
+ /**
3450
+ * Build the connection listener used by both {@link connect} and
3451
+ * {@link rebindToSharedClient}.
3452
+ *
3453
+ * Behavioral notes:
3454
+ * - When the Mux is sharing a {@link NostrClient} with the host
3455
+ * transport (#123), we deliberately do NOT emit
3456
+ * {@code transport:connected} / {@code transport:reconnecting} here
3457
+ * — the host transport's own listener already emits those for the
3458
+ * same socket event. Re-subscribing after a reconnect IS still our
3459
+ * responsibility, since the host has
3460
+ * {@code suppressSubscriptions()}'d its own filters.
3461
+ * - {@code onConnect} does not emit {@code transport:connected}.
3462
+ * The SDK only fires {@code onConnect} on the initial socket
3463
+ * connection (subsequent reconnects use {@code onReconnected}),
3464
+ * and {@link connect()}'s bottom already emits
3465
+ * {@code transport:connected} once that returns. Emitting here too
3466
+ * would double-fire on every initial connect.
3467
+ * - Each callback bails out early when the Mux is not in an active
3468
+ * state ({@code disconnected} / {@code error}). Listeners are
3469
+ * removed on {@code disconnect()} before the callback can fire,
3470
+ * so this guard is mainly defense-in-depth against any in-flight
3471
+ * callback that lands during teardown — but having it at the top
3472
+ * means we never emit a misleading {@code transport:connected}
3473
+ * from a Mux that has already torn down.
3474
+ */
3475
+ buildConnectionListener() {
3476
+ const isInactive = () => this.status === "disconnected" || this.status === "error";
3477
+ return {
3478
+ onConnect: (url) => {
3479
+ if (isInactive()) return;
3480
+ logger.debug("Mux", "Connected to relay:", url);
3481
+ },
3482
+ onDisconnect: (url, reason) => {
3483
+ logger.debug("Mux", "Disconnected from relay:", url, "reason:", reason);
3484
+ },
3485
+ onReconnecting: (url, attempt) => {
3486
+ if (isInactive()) return;
3487
+ logger.debug("Mux", "Reconnecting to relay:", url, "attempt:", attempt);
3488
+ if (!this.usingSharedClient) {
3489
+ this.emitEvent({ type: "transport:reconnecting", timestamp: Date.now() });
3490
+ }
3491
+ },
3492
+ onReconnected: (url) => {
3493
+ if (isInactive()) return;
3494
+ logger.debug("Mux", "Reconnected to relay:", url);
3495
+ if (!this.usingSharedClient) {
3496
+ this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3497
+ }
3498
+ this.updateSubscriptions().catch((err) => {
3499
+ logger.error("Mux", "Failed to re-subscribe after reconnect:", err);
3500
+ });
3501
+ }
3502
+ };
3503
+ }
3504
+ /**
3505
+ * Re-attach to a freshly-created shared NostrClient.
3506
+ *
3507
+ * Call this after the host (e.g. {@link NostrTransportProvider}) has
3508
+ * recreated its NostrClient — typically because the wallet's active
3509
+ * identity changed and the SDK's NostrClient does not support
3510
+ * changing identity at runtime. The previous client has already
3511
+ * been disconnected by the host, so its server-side subscriptions
3512
+ * are gone — we just adopt the new client and re-issue our own.
3513
+ *
3514
+ * The caller is responsible for ordering: by the time rebind runs,
3515
+ * the host transport's new NostrClient must already be created and
3516
+ * connected. In Sphere this is guaranteed because we await
3517
+ * {@code transport.setIdentity()} before calling rebind.
3518
+ *
3519
+ * Returns silently in two cases that are not caller errors:
3520
+ * - the Mux owns its own client (not sharing) — nothing to rebind
3521
+ * - the shared client reference hasn't changed (rebind is a no-op)
3522
+ *
3523
+ * Throws otherwise (rather than silently no-op'ing) so a wiring
3524
+ * mistake — for instance, calling rebind before the host's new
3525
+ * client is ready — surfaces immediately instead of leaving the
3526
+ * Mux pinned to a stale client.
3527
+ */
3528
+ async rebindToSharedClient() {
3529
+ if (!this.usingSharedClient) return;
3530
+ if (!this.sharedNostrClientGetter) return;
3531
+ const newClient = this.sharedNostrClientGetter();
3532
+ if (!newClient) {
3533
+ throw new SphereError(
3534
+ "rebindToSharedClient: shared client getter returned null. The host transport must finish (re)creating its NostrClient before rebind is called.",
3535
+ "TRANSPORT_ERROR"
3536
+ );
3537
+ }
3538
+ if (this.nostrClient === newClient) return;
3539
+ if (!newClient.isConnected()) {
3540
+ throw new SphereError(
3541
+ "rebindToSharedClient: new shared client is not connected. Await transport.setIdentity() / transport.connect() before rebinding.",
3542
+ "TRANSPORT_ERROR"
3543
+ );
3544
+ }
3545
+ if (this.nostrClient && this.connectionListener && this.nostrClient !== newClient) {
3546
+ try {
3547
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3548
+ } catch {
3549
+ }
3550
+ }
3551
+ this.nostrClient = newClient;
3552
+ this.walletSubscriptionId = null;
3553
+ this.chatSubscriptionId = null;
3554
+ this.chatEoseFired = false;
3555
+ this.connectionListener = this.buildConnectionListener();
3556
+ this.nostrClient.addConnectionListener(this.connectionListener);
3557
+ if (this.addresses.size > 0) {
3558
+ await this.updateSubscriptions();
3559
+ }
3560
+ }
3393
3561
  /**
3394
3562
  * One-shot fetch of pending events from the relay.
3395
3563
  * Creates a temporary subscription, waits for EOSE (or timeout),
@@ -3518,8 +3686,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3518
3686
  this.nostrClient.unsubscribe(this.chatSubscriptionId);
3519
3687
  this.chatSubscriptionId = null;
3520
3688
  }
3521
- this.lastWalletEventAt = Date.now();
3522
- this.lastChatEventAt = Date.now();
3523
3689
  if (this.addresses.size === 0) return;
3524
3690
  const allPubkeys = [];
3525
3691
  for (const entry of this.addresses.values()) {
@@ -3606,25 +3772,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3606
3772
  }
3607
3773
  });
3608
3774
  logger.debug("Mux", `updateSubscriptions: walletSub=${this.walletSubscriptionId} chatSub=${this.chatSubscriptionId}`);
3609
- this.startHealthCheck();
3610
- }
3611
- startHealthCheck() {
3612
- if (this.healthCheckTimer) return;
3613
- this.healthCheckTimer = setInterval(() => {
3614
- if (!this.isConnected()) return;
3615
- const chatElapsed = Date.now() - this.lastChatEventAt;
3616
- const walletElapsed = Date.now() - this.lastWalletEventAt;
3617
- const needResubscribe = chatElapsed > 6e4 || walletElapsed > 3e5;
3618
- if (needResubscribe) {
3619
- const reason = chatElapsed > 6e4 ? `No chat events for ${Math.round(chatElapsed / 1e3)}s` : `No wallet events for ${Math.round(walletElapsed / 1e3)}s`;
3620
- logger.warn("Mux", `${reason} \u2014 re-subscribing`);
3621
- this.lastChatEventAt = Date.now();
3622
- this.lastWalletEventAt = Date.now();
3623
- this.updateSubscriptions().catch((err) => {
3624
- logger.warn("Mux", "Health check re-subscription failed:", err);
3625
- });
3626
- }
3627
- }, 3e4);
3628
3775
  }
3629
3776
  /**
3630
3777
  * Schedule a re-subscription after a relay-initiated subscription closure.
@@ -3685,12 +3832,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3685
3832
  }
3686
3833
  }
3687
3834
  }
3688
- if (event.kind !== import_nostr_js_sdk2.EventKinds.GIFT_WRAP) {
3689
- this.lastWalletEventAt = Date.now();
3690
- }
3691
- if (event.kind === import_nostr_js_sdk2.EventKinds.GIFT_WRAP) {
3692
- this.lastChatEventAt = Date.now();
3693
- }
3694
3835
  try {
3695
3836
  if (event.kind === import_nostr_js_sdk2.EventKinds.GIFT_WRAP) {
3696
3837
  await this.routeGiftWrap(event);
@@ -28574,6 +28715,9 @@ var Sphere = class _Sphere {
28574
28715
  this._transport.setFallbackSince(fallbackTs);
28575
28716
  }
28576
28717
  await this._transport.setIdentity(this._identity);
28718
+ if (this._transportMux && typeof this._transportMux.rebindToSharedClient === "function") {
28719
+ await this._transportMux.rebindToSharedClient();
28720
+ }
28577
28721
  this.emitEvent("identity:changed", {
28578
28722
  l1Address: this._identity.l1Address,
28579
28723
  directAddress: this._identity.directAddress,
@@ -28784,7 +28928,12 @@ var Sphere = class _Sphere {
28784
28928
  this._transportMux = new MultiAddressTransportMux({
28785
28929
  relays: nostrTransport.getConfiguredRelays(),
28786
28930
  createWebSocket: nostrTransport.getWebSocketFactory(),
28787
- storage: nostrTransport.getStorageAdapter() ?? void 0
28931
+ storage: nostrTransport.getStorageAdapter() ?? void 0,
28932
+ // #123: share the original transport's NostrClient instead of
28933
+ // opening a second WebSocket per relay. Pass a getter so the
28934
+ // Mux resolves it at connect-time (after the transport finishes
28935
+ // its own connect()).
28936
+ sharedNostrClient: typeof nostrTransport.getNostrClient === "function" ? () => nostrTransport.getNostrClient() : void 0
28788
28937
  });
28789
28938
  await this._transportMux.connect();
28790
28939
  if (typeof nostrTransport.suppressSubscriptions === "function") {