@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.
@@ -768,8 +768,22 @@ interface MultiAddressTransportMuxConfig {
768
768
  storage?: TransportStorageAdapter;
769
769
  /** Private key for the Mux's NostrClient identity. If provided, the Mux
770
770
  * authenticates as this key — required for relays that filter gift-wrap
771
- * event delivery to the recipient's subscription. */
771
+ * event delivery to the recipient's subscription.
772
+ * Ignored when {@link sharedNostrClient} is set. */
772
773
  identityPrivateKey?: Uint8Array;
774
+ /**
775
+ * Optional pre-existing {@link NostrClient} to reuse instead of opening a
776
+ * fresh WebSocket per relay (#123). When set, the Mux skips both the
777
+ * {@code new NostrClient(...)} construction and {@code connect()} — it
778
+ * only registers subscription/connection listeners on the shared
779
+ * client. The Mux does NOT take ownership: its {@code disconnect()}
780
+ * leaves the client connected, since the caller (e.g. the original
781
+ * {@link NostrTransportProvider}) still uses it for resolve calls.
782
+ *
783
+ * Use a getter when the client may be created lazily (e.g. before the
784
+ * provider has connected).
785
+ */
786
+ sharedNostrClient?: NostrClient | null | (() => NostrClient | null);
773
787
  }
774
788
  declare class MultiAddressTransportMux {
775
789
  private config;
@@ -783,14 +797,14 @@ declare class MultiAddressTransportMux {
783
797
  private chatSubscriptionId;
784
798
  private chatEoseFired;
785
799
  private resubscribeTimer;
786
- private lastWalletEventAt;
787
- private lastChatEventAt;
788
- private healthCheckTimer;
789
800
  private chatEoseHandlers;
790
801
  private processedEventIds;
791
802
  private static readonly MAX_PROCESSED_IDS;
792
803
  private eventCallbacks;
793
804
  private readonly identityPrivateKey;
805
+ private readonly sharedNostrClientGetter;
806
+ private usingSharedClient;
807
+ private connectionListener;
794
808
  constructor(config: MultiAddressTransportMuxConfig);
795
809
  /**
796
810
  * Add an address to the multiplexer.
@@ -815,6 +829,58 @@ declare class MultiAddressTransportMux {
815
829
  connect(): Promise<void>;
816
830
  disconnect(): Promise<void>;
817
831
  isConnected(): boolean;
832
+ /**
833
+ * Build the connection listener used by both {@link connect} and
834
+ * {@link rebindToSharedClient}.
835
+ *
836
+ * Behavioral notes:
837
+ * - When the Mux is sharing a {@link NostrClient} with the host
838
+ * transport (#123), we deliberately do NOT emit
839
+ * {@code transport:connected} / {@code transport:reconnecting} here
840
+ * — the host transport's own listener already emits those for the
841
+ * same socket event. Re-subscribing after a reconnect IS still our
842
+ * responsibility, since the host has
843
+ * {@code suppressSubscriptions()}'d its own filters.
844
+ * - {@code onConnect} does not emit {@code transport:connected}.
845
+ * The SDK only fires {@code onConnect} on the initial socket
846
+ * connection (subsequent reconnects use {@code onReconnected}),
847
+ * and {@link connect()}'s bottom already emits
848
+ * {@code transport:connected} once that returns. Emitting here too
849
+ * would double-fire on every initial connect.
850
+ * - Each callback bails out early when the Mux is not in an active
851
+ * state ({@code disconnected} / {@code error}). Listeners are
852
+ * removed on {@code disconnect()} before the callback can fire,
853
+ * so this guard is mainly defense-in-depth against any in-flight
854
+ * callback that lands during teardown — but having it at the top
855
+ * means we never emit a misleading {@code transport:connected}
856
+ * from a Mux that has already torn down.
857
+ */
858
+ private buildConnectionListener;
859
+ /**
860
+ * Re-attach to a freshly-created shared NostrClient.
861
+ *
862
+ * Call this after the host (e.g. {@link NostrTransportProvider}) has
863
+ * recreated its NostrClient — typically because the wallet's active
864
+ * identity changed and the SDK's NostrClient does not support
865
+ * changing identity at runtime. The previous client has already
866
+ * been disconnected by the host, so its server-side subscriptions
867
+ * are gone — we just adopt the new client and re-issue our own.
868
+ *
869
+ * The caller is responsible for ordering: by the time rebind runs,
870
+ * the host transport's new NostrClient must already be created and
871
+ * connected. In Sphere this is guaranteed because we await
872
+ * {@code transport.setIdentity()} before calling rebind.
873
+ *
874
+ * Returns silently in two cases that are not caller errors:
875
+ * - the Mux owns its own client (not sharing) — nothing to rebind
876
+ * - the shared client reference hasn't changed (rebind is a no-op)
877
+ *
878
+ * Throws otherwise (rather than silently no-op'ing) so a wiring
879
+ * mistake — for instance, calling rebind before the host's new
880
+ * client is ready — surfaces immediately instead of leaving the
881
+ * Mux pinned to a stale client.
882
+ */
883
+ rebindToSharedClient(): Promise<void>;
818
884
  /**
819
885
  * One-shot fetch of pending events from the relay.
820
886
  * Creates a temporary subscription, waits for EOSE (or timeout),
@@ -835,7 +901,6 @@ declare class MultiAddressTransportMux {
835
901
  * Called whenever addresses are added/removed.
836
902
  */
837
903
  private updateSubscriptions;
838
- private startHealthCheck;
839
904
  /**
840
905
  * Schedule a re-subscription after a relay-initiated subscription closure.
841
906
  * Debounced: if both wallet and chat subscriptions fire onError in quick
@@ -768,8 +768,22 @@ interface MultiAddressTransportMuxConfig {
768
768
  storage?: TransportStorageAdapter;
769
769
  /** Private key for the Mux's NostrClient identity. If provided, the Mux
770
770
  * authenticates as this key — required for relays that filter gift-wrap
771
- * event delivery to the recipient's subscription. */
771
+ * event delivery to the recipient's subscription.
772
+ * Ignored when {@link sharedNostrClient} is set. */
772
773
  identityPrivateKey?: Uint8Array;
774
+ /**
775
+ * Optional pre-existing {@link NostrClient} to reuse instead of opening a
776
+ * fresh WebSocket per relay (#123). When set, the Mux skips both the
777
+ * {@code new NostrClient(...)} construction and {@code connect()} — it
778
+ * only registers subscription/connection listeners on the shared
779
+ * client. The Mux does NOT take ownership: its {@code disconnect()}
780
+ * leaves the client connected, since the caller (e.g. the original
781
+ * {@link NostrTransportProvider}) still uses it for resolve calls.
782
+ *
783
+ * Use a getter when the client may be created lazily (e.g. before the
784
+ * provider has connected).
785
+ */
786
+ sharedNostrClient?: NostrClient | null | (() => NostrClient | null);
773
787
  }
774
788
  declare class MultiAddressTransportMux {
775
789
  private config;
@@ -783,14 +797,14 @@ declare class MultiAddressTransportMux {
783
797
  private chatSubscriptionId;
784
798
  private chatEoseFired;
785
799
  private resubscribeTimer;
786
- private lastWalletEventAt;
787
- private lastChatEventAt;
788
- private healthCheckTimer;
789
800
  private chatEoseHandlers;
790
801
  private processedEventIds;
791
802
  private static readonly MAX_PROCESSED_IDS;
792
803
  private eventCallbacks;
793
804
  private readonly identityPrivateKey;
805
+ private readonly sharedNostrClientGetter;
806
+ private usingSharedClient;
807
+ private connectionListener;
794
808
  constructor(config: MultiAddressTransportMuxConfig);
795
809
  /**
796
810
  * Add an address to the multiplexer.
@@ -815,6 +829,58 @@ declare class MultiAddressTransportMux {
815
829
  connect(): Promise<void>;
816
830
  disconnect(): Promise<void>;
817
831
  isConnected(): boolean;
832
+ /**
833
+ * Build the connection listener used by both {@link connect} and
834
+ * {@link rebindToSharedClient}.
835
+ *
836
+ * Behavioral notes:
837
+ * - When the Mux is sharing a {@link NostrClient} with the host
838
+ * transport (#123), we deliberately do NOT emit
839
+ * {@code transport:connected} / {@code transport:reconnecting} here
840
+ * — the host transport's own listener already emits those for the
841
+ * same socket event. Re-subscribing after a reconnect IS still our
842
+ * responsibility, since the host has
843
+ * {@code suppressSubscriptions()}'d its own filters.
844
+ * - {@code onConnect} does not emit {@code transport:connected}.
845
+ * The SDK only fires {@code onConnect} on the initial socket
846
+ * connection (subsequent reconnects use {@code onReconnected}),
847
+ * and {@link connect()}'s bottom already emits
848
+ * {@code transport:connected} once that returns. Emitting here too
849
+ * would double-fire on every initial connect.
850
+ * - Each callback bails out early when the Mux is not in an active
851
+ * state ({@code disconnected} / {@code error}). Listeners are
852
+ * removed on {@code disconnect()} before the callback can fire,
853
+ * so this guard is mainly defense-in-depth against any in-flight
854
+ * callback that lands during teardown — but having it at the top
855
+ * means we never emit a misleading {@code transport:connected}
856
+ * from a Mux that has already torn down.
857
+ */
858
+ private buildConnectionListener;
859
+ /**
860
+ * Re-attach to a freshly-created shared NostrClient.
861
+ *
862
+ * Call this after the host (e.g. {@link NostrTransportProvider}) has
863
+ * recreated its NostrClient — typically because the wallet's active
864
+ * identity changed and the SDK's NostrClient does not support
865
+ * changing identity at runtime. The previous client has already
866
+ * been disconnected by the host, so its server-side subscriptions
867
+ * are gone — we just adopt the new client and re-issue our own.
868
+ *
869
+ * The caller is responsible for ordering: by the time rebind runs,
870
+ * the host transport's new NostrClient must already be created and
871
+ * connected. In Sphere this is guaranteed because we await
872
+ * {@code transport.setIdentity()} before calling rebind.
873
+ *
874
+ * Returns silently in two cases that are not caller errors:
875
+ * - the Mux owns its own client (not sharing) — nothing to rebind
876
+ * - the shared client reference hasn't changed (rebind is a no-op)
877
+ *
878
+ * Throws otherwise (rather than silently no-op'ing) so a wiring
879
+ * mistake — for instance, calling rebind before the host's new
880
+ * client is ready — surfaces immediately instead of leaving the
881
+ * Mux pinned to a stale client.
882
+ */
883
+ rebindToSharedClient(): Promise<void>;
818
884
  /**
819
885
  * One-shot fetch of pending events from the relay.
820
886
  * Creates a temporary subscription, waits for EOSE (or timeout),
@@ -835,7 +901,6 @@ declare class MultiAddressTransportMux {
835
901
  * Called whenever addresses are added/removed.
836
902
  */
837
903
  private updateSubscriptions;
838
- private startHealthCheck;
839
904
  /**
840
905
  * Schedule a re-subscription after a relay-initiated subscription closure.
841
906
  * Debounced: if both wallet and chat subscriptions fire onError in quick
@@ -1365,6 +1365,17 @@ var NostrTransportProvider = class _NostrTransportProvider {
1365
1365
  getStorageAdapter() {
1366
1366
  return this.storage;
1367
1367
  }
1368
+ /**
1369
+ * Get the underlying NostrClient (or null if not yet connected).
1370
+ *
1371
+ * Exposed so {@link MultiAddressTransportMux} can share the same
1372
+ * client/socket pair instead of opening a duplicate WebSocket per
1373
+ * relay (#123). The transport owns the client's lifecycle — callers
1374
+ * MUST NOT call {@code disconnect()} on the returned instance.
1375
+ */
1376
+ getNostrClient() {
1377
+ return this.nostrClient;
1378
+ }
1368
1379
  /**
1369
1380
  * Suppress event subscriptions — unsubscribe wallet/chat filters
1370
1381
  * but keep the connection alive for resolve/identity-binding operations.
@@ -3003,9 +3014,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3003
3014
  chatSubscriptionId = null;
3004
3015
  chatEoseFired = false;
3005
3016
  resubscribeTimer = null;
3006
- lastWalletEventAt = Date.now();
3007
- lastChatEventAt = Date.now();
3008
- healthCheckTimer = null;
3009
3017
  chatEoseHandlers = [];
3010
3018
  // Dedup — bounded to prevent memory leak in long-running sessions.
3011
3019
  // Set preserves insertion order; evict oldest entries when cap is reached.
@@ -3016,6 +3024,19 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3016
3024
  // Identity key for the Mux's NostrClient — relays may filter gift-wrap
3017
3025
  // delivery to the recipient's subscription key.
3018
3026
  identityPrivateKey;
3027
+ // Resolves the shared NostrClient at use-time (the source provider may
3028
+ // create its client lazily, after the Mux is constructed). null means
3029
+ // "no shared client; create our own."
3030
+ sharedNostrClientGetter;
3031
+ // True when this Mux is using a shared NostrClient and therefore must
3032
+ // not call connect()/disconnect() on it.
3033
+ usingSharedClient = false;
3034
+ // Listener registered on the underlying NostrClient. Tracked so we can
3035
+ // remove it on disconnect / rebind — otherwise a long-lived shared
3036
+ // client accumulates listeners across address switches and (worse)
3037
+ // a "disconnected" Mux still sees onReconnected callbacks fire and
3038
+ // re-establish subscriptions it shouldn't have.
3039
+ connectionListener = null;
3019
3040
  constructor(config) {
3020
3041
  this.identityPrivateKey = config.identityPrivateKey;
3021
3042
  this.config = {
@@ -3028,6 +3049,14 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3028
3049
  generateUUID: config.generateUUID ?? defaultUUIDGenerator
3029
3050
  };
3030
3051
  this.storage = config.storage ?? null;
3052
+ if (typeof config.sharedNostrClient === "function") {
3053
+ this.sharedNostrClientGetter = config.sharedNostrClient;
3054
+ } else if (config.sharedNostrClient) {
3055
+ const c = config.sharedNostrClient;
3056
+ this.sharedNostrClientGetter = () => c;
3057
+ } else {
3058
+ this.sharedNostrClientGetter = null;
3059
+ }
3031
3060
  }
3032
3061
  // ===========================================================================
3033
3062
  // Address Management
@@ -3118,53 +3147,49 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3118
3147
  if (this.status === "connected") return;
3119
3148
  this.status = "connecting";
3120
3149
  try {
3121
- if (!this.primaryKeyManager) {
3122
- if (this.identityPrivateKey) {
3123
- this.primaryKeyManager = NostrKeyManager2.fromPrivateKey(
3124
- Buffer3.from(this.identityPrivateKey)
3150
+ const shared = this.sharedNostrClientGetter ? this.sharedNostrClientGetter() : null;
3151
+ if (shared) {
3152
+ if (!shared.isConnected()) {
3153
+ throw new SphereError(
3154
+ "sharedNostrClient is not connected; the Mux cannot share a closed socket",
3155
+ "TRANSPORT_ERROR"
3125
3156
  );
3126
- } else {
3127
- const tempKey = Buffer3.alloc(32);
3128
- crypto.getRandomValues(tempKey);
3129
- this.primaryKeyManager = NostrKeyManager2.fromPrivateKey(tempKey);
3130
3157
  }
3158
+ this.nostrClient = shared;
3159
+ this.usingSharedClient = true;
3160
+ } else {
3161
+ if (!this.primaryKeyManager) {
3162
+ if (this.identityPrivateKey) {
3163
+ this.primaryKeyManager = NostrKeyManager2.fromPrivateKey(
3164
+ Buffer3.from(this.identityPrivateKey)
3165
+ );
3166
+ } else {
3167
+ const tempKey = Buffer3.alloc(32);
3168
+ crypto.getRandomValues(tempKey);
3169
+ this.primaryKeyManager = NostrKeyManager2.fromPrivateKey(tempKey);
3170
+ }
3171
+ }
3172
+ this.nostrClient = new NostrClient2(this.primaryKeyManager, {
3173
+ autoReconnect: this.config.autoReconnect,
3174
+ reconnectIntervalMs: this.config.reconnectDelay,
3175
+ maxReconnectIntervalMs: this.config.reconnectDelay * 16,
3176
+ pingIntervalMs: 15e3
3177
+ });
3131
3178
  }
3132
- this.nostrClient = new NostrClient2(this.primaryKeyManager, {
3133
- autoReconnect: this.config.autoReconnect,
3134
- reconnectIntervalMs: this.config.reconnectDelay,
3135
- maxReconnectIntervalMs: this.config.reconnectDelay * 16,
3136
- pingIntervalMs: 15e3
3137
- });
3138
- this.nostrClient.addConnectionListener({
3139
- onConnect: (url) => {
3140
- logger.debug("Mux", "Connected to relay:", url);
3141
- this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3142
- },
3143
- onDisconnect: (url, reason) => {
3144
- logger.debug("Mux", "Disconnected from relay:", url, "reason:", reason);
3145
- },
3146
- onReconnecting: (url, attempt) => {
3147
- logger.debug("Mux", "Reconnecting to relay:", url, "attempt:", attempt);
3148
- this.emitEvent({ type: "transport:reconnecting", timestamp: Date.now() });
3149
- },
3150
- onReconnected: (url) => {
3151
- logger.debug("Mux", "Reconnected to relay:", url);
3152
- this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3153
- this.updateSubscriptions().catch((err) => {
3154
- logger.error("Mux", "Failed to re-subscribe after reconnect:", err);
3155
- });
3179
+ this.connectionListener = this.buildConnectionListener();
3180
+ this.nostrClient.addConnectionListener(this.connectionListener);
3181
+ if (!this.usingSharedClient) {
3182
+ await Promise.race([
3183
+ this.nostrClient.connect(...this.config.relays),
3184
+ new Promise(
3185
+ (_, reject) => setTimeout(() => reject(new Error(
3186
+ `Transport connection timed out after ${this.config.timeout}ms`
3187
+ )), this.config.timeout)
3188
+ )
3189
+ ]);
3190
+ if (!this.nostrClient.isConnected()) {
3191
+ throw new SphereError("Failed to connect to any relay", "TRANSPORT_ERROR");
3156
3192
  }
3157
- });
3158
- await Promise.race([
3159
- this.nostrClient.connect(...this.config.relays),
3160
- new Promise(
3161
- (_, reject) => setTimeout(() => reject(new Error(
3162
- `Transport connection timed out after ${this.config.timeout}ms`
3163
- )), this.config.timeout)
3164
- )
3165
- ]);
3166
- if (!this.nostrClient.isConnected()) {
3167
- throw new SphereError("Failed to connect to any relay", "TRANSPORT_ERROR");
3168
3193
  }
3169
3194
  this.status = "connected";
3170
3195
  this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
@@ -3173,6 +3198,21 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3173
3198
  }
3174
3199
  } catch (error) {
3175
3200
  this.status = "error";
3201
+ if (this.connectionListener && this.nostrClient) {
3202
+ try {
3203
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3204
+ } catch {
3205
+ }
3206
+ }
3207
+ this.connectionListener = null;
3208
+ if (this.nostrClient && !this.usingSharedClient) {
3209
+ try {
3210
+ this.nostrClient.disconnect();
3211
+ } catch {
3212
+ }
3213
+ }
3214
+ this.nostrClient = null;
3215
+ this.usingSharedClient = false;
3176
3216
  throw error;
3177
3217
  }
3178
3218
  }
@@ -3181,25 +3221,153 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3181
3221
  clearTimeout(this.resubscribeTimer);
3182
3222
  this.resubscribeTimer = null;
3183
3223
  }
3184
- if (this.healthCheckTimer) {
3185
- clearInterval(this.healthCheckTimer);
3186
- this.healthCheckTimer = null;
3187
- }
3188
3224
  if (this.nostrClient) {
3189
- this.nostrClient.disconnect();
3225
+ if (this.walletSubscriptionId) {
3226
+ try {
3227
+ this.nostrClient.unsubscribe(this.walletSubscriptionId);
3228
+ } catch {
3229
+ }
3230
+ }
3231
+ if (this.chatSubscriptionId) {
3232
+ try {
3233
+ this.nostrClient.unsubscribe(this.chatSubscriptionId);
3234
+ } catch {
3235
+ }
3236
+ }
3237
+ if (this.connectionListener) {
3238
+ try {
3239
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3240
+ } catch {
3241
+ }
3242
+ }
3243
+ if (!this.usingSharedClient) {
3244
+ this.nostrClient.disconnect();
3245
+ }
3190
3246
  this.nostrClient = null;
3191
3247
  }
3248
+ this.connectionListener = null;
3249
+ this.usingSharedClient = false;
3192
3250
  this.walletSubscriptionId = null;
3193
3251
  this.chatSubscriptionId = null;
3194
3252
  this.chatEoseFired = false;
3195
- this.lastWalletEventAt = Date.now();
3196
- this.lastChatEventAt = Date.now();
3197
3253
  this.status = "disconnected";
3198
3254
  this.emitEvent({ type: "transport:disconnected", timestamp: Date.now() });
3199
3255
  }
3200
3256
  isConnected() {
3201
3257
  return this.status === "connected" && this.nostrClient?.isConnected() === true;
3202
3258
  }
3259
+ /**
3260
+ * Build the connection listener used by both {@link connect} and
3261
+ * {@link rebindToSharedClient}.
3262
+ *
3263
+ * Behavioral notes:
3264
+ * - When the Mux is sharing a {@link NostrClient} with the host
3265
+ * transport (#123), we deliberately do NOT emit
3266
+ * {@code transport:connected} / {@code transport:reconnecting} here
3267
+ * — the host transport's own listener already emits those for the
3268
+ * same socket event. Re-subscribing after a reconnect IS still our
3269
+ * responsibility, since the host has
3270
+ * {@code suppressSubscriptions()}'d its own filters.
3271
+ * - {@code onConnect} does not emit {@code transport:connected}.
3272
+ * The SDK only fires {@code onConnect} on the initial socket
3273
+ * connection (subsequent reconnects use {@code onReconnected}),
3274
+ * and {@link connect()}'s bottom already emits
3275
+ * {@code transport:connected} once that returns. Emitting here too
3276
+ * would double-fire on every initial connect.
3277
+ * - Each callback bails out early when the Mux is not in an active
3278
+ * state ({@code disconnected} / {@code error}). Listeners are
3279
+ * removed on {@code disconnect()} before the callback can fire,
3280
+ * so this guard is mainly defense-in-depth against any in-flight
3281
+ * callback that lands during teardown — but having it at the top
3282
+ * means we never emit a misleading {@code transport:connected}
3283
+ * from a Mux that has already torn down.
3284
+ */
3285
+ buildConnectionListener() {
3286
+ const isInactive = () => this.status === "disconnected" || this.status === "error";
3287
+ return {
3288
+ onConnect: (url) => {
3289
+ if (isInactive()) return;
3290
+ logger.debug("Mux", "Connected to relay:", url);
3291
+ },
3292
+ onDisconnect: (url, reason) => {
3293
+ logger.debug("Mux", "Disconnected from relay:", url, "reason:", reason);
3294
+ },
3295
+ onReconnecting: (url, attempt) => {
3296
+ if (isInactive()) return;
3297
+ logger.debug("Mux", "Reconnecting to relay:", url, "attempt:", attempt);
3298
+ if (!this.usingSharedClient) {
3299
+ this.emitEvent({ type: "transport:reconnecting", timestamp: Date.now() });
3300
+ }
3301
+ },
3302
+ onReconnected: (url) => {
3303
+ if (isInactive()) return;
3304
+ logger.debug("Mux", "Reconnected to relay:", url);
3305
+ if (!this.usingSharedClient) {
3306
+ this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3307
+ }
3308
+ this.updateSubscriptions().catch((err) => {
3309
+ logger.error("Mux", "Failed to re-subscribe after reconnect:", err);
3310
+ });
3311
+ }
3312
+ };
3313
+ }
3314
+ /**
3315
+ * Re-attach to a freshly-created shared NostrClient.
3316
+ *
3317
+ * Call this after the host (e.g. {@link NostrTransportProvider}) has
3318
+ * recreated its NostrClient — typically because the wallet's active
3319
+ * identity changed and the SDK's NostrClient does not support
3320
+ * changing identity at runtime. The previous client has already
3321
+ * been disconnected by the host, so its server-side subscriptions
3322
+ * are gone — we just adopt the new client and re-issue our own.
3323
+ *
3324
+ * The caller is responsible for ordering: by the time rebind runs,
3325
+ * the host transport's new NostrClient must already be created and
3326
+ * connected. In Sphere this is guaranteed because we await
3327
+ * {@code transport.setIdentity()} before calling rebind.
3328
+ *
3329
+ * Returns silently in two cases that are not caller errors:
3330
+ * - the Mux owns its own client (not sharing) — nothing to rebind
3331
+ * - the shared client reference hasn't changed (rebind is a no-op)
3332
+ *
3333
+ * Throws otherwise (rather than silently no-op'ing) so a wiring
3334
+ * mistake — for instance, calling rebind before the host's new
3335
+ * client is ready — surfaces immediately instead of leaving the
3336
+ * Mux pinned to a stale client.
3337
+ */
3338
+ async rebindToSharedClient() {
3339
+ if (!this.usingSharedClient) return;
3340
+ if (!this.sharedNostrClientGetter) return;
3341
+ const newClient = this.sharedNostrClientGetter();
3342
+ if (!newClient) {
3343
+ throw new SphereError(
3344
+ "rebindToSharedClient: shared client getter returned null. The host transport must finish (re)creating its NostrClient before rebind is called.",
3345
+ "TRANSPORT_ERROR"
3346
+ );
3347
+ }
3348
+ if (this.nostrClient === newClient) return;
3349
+ if (!newClient.isConnected()) {
3350
+ throw new SphereError(
3351
+ "rebindToSharedClient: new shared client is not connected. Await transport.setIdentity() / transport.connect() before rebinding.",
3352
+ "TRANSPORT_ERROR"
3353
+ );
3354
+ }
3355
+ if (this.nostrClient && this.connectionListener && this.nostrClient !== newClient) {
3356
+ try {
3357
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3358
+ } catch {
3359
+ }
3360
+ }
3361
+ this.nostrClient = newClient;
3362
+ this.walletSubscriptionId = null;
3363
+ this.chatSubscriptionId = null;
3364
+ this.chatEoseFired = false;
3365
+ this.connectionListener = this.buildConnectionListener();
3366
+ this.nostrClient.addConnectionListener(this.connectionListener);
3367
+ if (this.addresses.size > 0) {
3368
+ await this.updateSubscriptions();
3369
+ }
3370
+ }
3203
3371
  /**
3204
3372
  * One-shot fetch of pending events from the relay.
3205
3373
  * Creates a temporary subscription, waits for EOSE (or timeout),
@@ -3328,8 +3496,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3328
3496
  this.nostrClient.unsubscribe(this.chatSubscriptionId);
3329
3497
  this.chatSubscriptionId = null;
3330
3498
  }
3331
- this.lastWalletEventAt = Date.now();
3332
- this.lastChatEventAt = Date.now();
3333
3499
  if (this.addresses.size === 0) return;
3334
3500
  const allPubkeys = [];
3335
3501
  for (const entry of this.addresses.values()) {
@@ -3416,25 +3582,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3416
3582
  }
3417
3583
  });
3418
3584
  logger.debug("Mux", `updateSubscriptions: walletSub=${this.walletSubscriptionId} chatSub=${this.chatSubscriptionId}`);
3419
- this.startHealthCheck();
3420
- }
3421
- startHealthCheck() {
3422
- if (this.healthCheckTimer) return;
3423
- this.healthCheckTimer = setInterval(() => {
3424
- if (!this.isConnected()) return;
3425
- const chatElapsed = Date.now() - this.lastChatEventAt;
3426
- const walletElapsed = Date.now() - this.lastWalletEventAt;
3427
- const needResubscribe = chatElapsed > 6e4 || walletElapsed > 3e5;
3428
- if (needResubscribe) {
3429
- const reason = chatElapsed > 6e4 ? `No chat events for ${Math.round(chatElapsed / 1e3)}s` : `No wallet events for ${Math.round(walletElapsed / 1e3)}s`;
3430
- logger.warn("Mux", `${reason} \u2014 re-subscribing`);
3431
- this.lastChatEventAt = Date.now();
3432
- this.lastWalletEventAt = Date.now();
3433
- this.updateSubscriptions().catch((err) => {
3434
- logger.warn("Mux", "Health check re-subscription failed:", err);
3435
- });
3436
- }
3437
- }, 3e4);
3438
3585
  }
3439
3586
  /**
3440
3587
  * Schedule a re-subscription after a relay-initiated subscription closure.
@@ -3495,12 +3642,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3495
3642
  }
3496
3643
  }
3497
3644
  }
3498
- if (event.kind !== EventKinds2.GIFT_WRAP) {
3499
- this.lastWalletEventAt = Date.now();
3500
- }
3501
- if (event.kind === EventKinds2.GIFT_WRAP) {
3502
- this.lastChatEventAt = Date.now();
3503
- }
3504
3645
  try {
3505
3646
  if (event.kind === EventKinds2.GIFT_WRAP) {
3506
3647
  await this.routeGiftWrap(event);
@@ -28219,6 +28360,9 @@ var Sphere = class _Sphere {
28219
28360
  this._transport.setFallbackSince(fallbackTs);
28220
28361
  }
28221
28362
  await this._transport.setIdentity(this._identity);
28363
+ if (this._transportMux && typeof this._transportMux.rebindToSharedClient === "function") {
28364
+ await this._transportMux.rebindToSharedClient();
28365
+ }
28222
28366
  this.emitEvent("identity:changed", {
28223
28367
  l1Address: this._identity.l1Address,
28224
28368
  directAddress: this._identity.directAddress,
@@ -28429,7 +28573,12 @@ var Sphere = class _Sphere {
28429
28573
  this._transportMux = new MultiAddressTransportMux({
28430
28574
  relays: nostrTransport.getConfiguredRelays(),
28431
28575
  createWebSocket: nostrTransport.getWebSocketFactory(),
28432
- storage: nostrTransport.getStorageAdapter() ?? void 0
28576
+ storage: nostrTransport.getStorageAdapter() ?? void 0,
28577
+ // #123: share the original transport's NostrClient instead of
28578
+ // opening a second WebSocket per relay. Pass a getter so the
28579
+ // Mux resolves it at connect-time (after the transport finishes
28580
+ // its own connect()).
28581
+ sharedNostrClient: typeof nostrTransport.getNostrClient === "function" ? () => nostrTransport.getNostrClient() : void 0
28433
28582
  });
28434
28583
  await this._transportMux.connect();
28435
28584
  if (typeof nostrTransport.suppressSubscriptions === "function") {