@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.
package/dist/index.js CHANGED
@@ -1389,6 +1389,17 @@ var NostrTransportProvider = class _NostrTransportProvider {
1389
1389
  getStorageAdapter() {
1390
1390
  return this.storage;
1391
1391
  }
1392
+ /**
1393
+ * Get the underlying NostrClient (or null if not yet connected).
1394
+ *
1395
+ * Exposed so {@link MultiAddressTransportMux} can share the same
1396
+ * client/socket pair instead of opening a duplicate WebSocket per
1397
+ * relay (#123). The transport owns the client's lifecycle — callers
1398
+ * MUST NOT call {@code disconnect()} on the returned instance.
1399
+ */
1400
+ getNostrClient() {
1401
+ return this.nostrClient;
1402
+ }
1392
1403
  /**
1393
1404
  * Suppress event subscriptions — unsubscribe wallet/chat filters
1394
1405
  * but keep the connection alive for resolve/identity-binding operations.
@@ -3027,9 +3038,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3027
3038
  chatSubscriptionId = null;
3028
3039
  chatEoseFired = false;
3029
3040
  resubscribeTimer = null;
3030
- lastWalletEventAt = Date.now();
3031
- lastChatEventAt = Date.now();
3032
- healthCheckTimer = null;
3033
3041
  chatEoseHandlers = [];
3034
3042
  // Dedup — bounded to prevent memory leak in long-running sessions.
3035
3043
  // Set preserves insertion order; evict oldest entries when cap is reached.
@@ -3040,6 +3048,19 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3040
3048
  // Identity key for the Mux's NostrClient — relays may filter gift-wrap
3041
3049
  // delivery to the recipient's subscription key.
3042
3050
  identityPrivateKey;
3051
+ // Resolves the shared NostrClient at use-time (the source provider may
3052
+ // create its client lazily, after the Mux is constructed). null means
3053
+ // "no shared client; create our own."
3054
+ sharedNostrClientGetter;
3055
+ // True when this Mux is using a shared NostrClient and therefore must
3056
+ // not call connect()/disconnect() on it.
3057
+ usingSharedClient = false;
3058
+ // Listener registered on the underlying NostrClient. Tracked so we can
3059
+ // remove it on disconnect / rebind — otherwise a long-lived shared
3060
+ // client accumulates listeners across address switches and (worse)
3061
+ // a "disconnected" Mux still sees onReconnected callbacks fire and
3062
+ // re-establish subscriptions it shouldn't have.
3063
+ connectionListener = null;
3043
3064
  constructor(config) {
3044
3065
  this.identityPrivateKey = config.identityPrivateKey;
3045
3066
  this.config = {
@@ -3052,6 +3073,14 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3052
3073
  generateUUID: config.generateUUID ?? defaultUUIDGenerator
3053
3074
  };
3054
3075
  this.storage = config.storage ?? null;
3076
+ if (typeof config.sharedNostrClient === "function") {
3077
+ this.sharedNostrClientGetter = config.sharedNostrClient;
3078
+ } else if (config.sharedNostrClient) {
3079
+ const c = config.sharedNostrClient;
3080
+ this.sharedNostrClientGetter = () => c;
3081
+ } else {
3082
+ this.sharedNostrClientGetter = null;
3083
+ }
3055
3084
  }
3056
3085
  // ===========================================================================
3057
3086
  // Address Management
@@ -3142,53 +3171,49 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3142
3171
  if (this.status === "connected") return;
3143
3172
  this.status = "connecting";
3144
3173
  try {
3145
- if (!this.primaryKeyManager) {
3146
- if (this.identityPrivateKey) {
3147
- this.primaryKeyManager = NostrKeyManager2.fromPrivateKey(
3148
- Buffer3.from(this.identityPrivateKey)
3174
+ const shared = this.sharedNostrClientGetter ? this.sharedNostrClientGetter() : null;
3175
+ if (shared) {
3176
+ if (!shared.isConnected()) {
3177
+ throw new SphereError(
3178
+ "sharedNostrClient is not connected; the Mux cannot share a closed socket",
3179
+ "TRANSPORT_ERROR"
3149
3180
  );
3150
- } else {
3151
- const tempKey = Buffer3.alloc(32);
3152
- crypto.getRandomValues(tempKey);
3153
- this.primaryKeyManager = NostrKeyManager2.fromPrivateKey(tempKey);
3154
3181
  }
3182
+ this.nostrClient = shared;
3183
+ this.usingSharedClient = true;
3184
+ } else {
3185
+ if (!this.primaryKeyManager) {
3186
+ if (this.identityPrivateKey) {
3187
+ this.primaryKeyManager = NostrKeyManager2.fromPrivateKey(
3188
+ Buffer3.from(this.identityPrivateKey)
3189
+ );
3190
+ } else {
3191
+ const tempKey = Buffer3.alloc(32);
3192
+ crypto.getRandomValues(tempKey);
3193
+ this.primaryKeyManager = NostrKeyManager2.fromPrivateKey(tempKey);
3194
+ }
3195
+ }
3196
+ this.nostrClient = new NostrClient2(this.primaryKeyManager, {
3197
+ autoReconnect: this.config.autoReconnect,
3198
+ reconnectIntervalMs: this.config.reconnectDelay,
3199
+ maxReconnectIntervalMs: this.config.reconnectDelay * 16,
3200
+ pingIntervalMs: 15e3
3201
+ });
3155
3202
  }
3156
- this.nostrClient = new NostrClient2(this.primaryKeyManager, {
3157
- autoReconnect: this.config.autoReconnect,
3158
- reconnectIntervalMs: this.config.reconnectDelay,
3159
- maxReconnectIntervalMs: this.config.reconnectDelay * 16,
3160
- pingIntervalMs: 15e3
3161
- });
3162
- this.nostrClient.addConnectionListener({
3163
- onConnect: (url) => {
3164
- logger.debug("Mux", "Connected to relay:", url);
3165
- this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3166
- },
3167
- onDisconnect: (url, reason) => {
3168
- logger.debug("Mux", "Disconnected from relay:", url, "reason:", reason);
3169
- },
3170
- onReconnecting: (url, attempt) => {
3171
- logger.debug("Mux", "Reconnecting to relay:", url, "attempt:", attempt);
3172
- this.emitEvent({ type: "transport:reconnecting", timestamp: Date.now() });
3173
- },
3174
- onReconnected: (url) => {
3175
- logger.debug("Mux", "Reconnected to relay:", url);
3176
- this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3177
- this.updateSubscriptions().catch((err) => {
3178
- logger.error("Mux", "Failed to re-subscribe after reconnect:", err);
3179
- });
3203
+ this.connectionListener = this.buildConnectionListener();
3204
+ this.nostrClient.addConnectionListener(this.connectionListener);
3205
+ if (!this.usingSharedClient) {
3206
+ await Promise.race([
3207
+ this.nostrClient.connect(...this.config.relays),
3208
+ new Promise(
3209
+ (_, reject) => setTimeout(() => reject(new Error(
3210
+ `Transport connection timed out after ${this.config.timeout}ms`
3211
+ )), this.config.timeout)
3212
+ )
3213
+ ]);
3214
+ if (!this.nostrClient.isConnected()) {
3215
+ throw new SphereError("Failed to connect to any relay", "TRANSPORT_ERROR");
3180
3216
  }
3181
- });
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");
3192
3217
  }
3193
3218
  this.status = "connected";
3194
3219
  this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
@@ -3197,6 +3222,21 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3197
3222
  }
3198
3223
  } catch (error) {
3199
3224
  this.status = "error";
3225
+ if (this.connectionListener && this.nostrClient) {
3226
+ try {
3227
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3228
+ } catch {
3229
+ }
3230
+ }
3231
+ this.connectionListener = null;
3232
+ if (this.nostrClient && !this.usingSharedClient) {
3233
+ try {
3234
+ this.nostrClient.disconnect();
3235
+ } catch {
3236
+ }
3237
+ }
3238
+ this.nostrClient = null;
3239
+ this.usingSharedClient = false;
3200
3240
  throw error;
3201
3241
  }
3202
3242
  }
@@ -3205,25 +3245,153 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3205
3245
  clearTimeout(this.resubscribeTimer);
3206
3246
  this.resubscribeTimer = null;
3207
3247
  }
3208
- if (this.healthCheckTimer) {
3209
- clearInterval(this.healthCheckTimer);
3210
- this.healthCheckTimer = null;
3211
- }
3212
3248
  if (this.nostrClient) {
3213
- this.nostrClient.disconnect();
3249
+ if (this.walletSubscriptionId) {
3250
+ try {
3251
+ this.nostrClient.unsubscribe(this.walletSubscriptionId);
3252
+ } catch {
3253
+ }
3254
+ }
3255
+ if (this.chatSubscriptionId) {
3256
+ try {
3257
+ this.nostrClient.unsubscribe(this.chatSubscriptionId);
3258
+ } catch {
3259
+ }
3260
+ }
3261
+ if (this.connectionListener) {
3262
+ try {
3263
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3264
+ } catch {
3265
+ }
3266
+ }
3267
+ if (!this.usingSharedClient) {
3268
+ this.nostrClient.disconnect();
3269
+ }
3214
3270
  this.nostrClient = null;
3215
3271
  }
3272
+ this.connectionListener = null;
3273
+ this.usingSharedClient = false;
3216
3274
  this.walletSubscriptionId = null;
3217
3275
  this.chatSubscriptionId = null;
3218
3276
  this.chatEoseFired = false;
3219
- this.lastWalletEventAt = Date.now();
3220
- this.lastChatEventAt = Date.now();
3221
3277
  this.status = "disconnected";
3222
3278
  this.emitEvent({ type: "transport:disconnected", timestamp: Date.now() });
3223
3279
  }
3224
3280
  isConnected() {
3225
3281
  return this.status === "connected" && this.nostrClient?.isConnected() === true;
3226
3282
  }
3283
+ /**
3284
+ * Build the connection listener used by both {@link connect} and
3285
+ * {@link rebindToSharedClient}.
3286
+ *
3287
+ * Behavioral notes:
3288
+ * - When the Mux is sharing a {@link NostrClient} with the host
3289
+ * transport (#123), we deliberately do NOT emit
3290
+ * {@code transport:connected} / {@code transport:reconnecting} here
3291
+ * — the host transport's own listener already emits those for the
3292
+ * same socket event. Re-subscribing after a reconnect IS still our
3293
+ * responsibility, since the host has
3294
+ * {@code suppressSubscriptions()}'d its own filters.
3295
+ * - {@code onConnect} does not emit {@code transport:connected}.
3296
+ * The SDK only fires {@code onConnect} on the initial socket
3297
+ * connection (subsequent reconnects use {@code onReconnected}),
3298
+ * and {@link connect()}'s bottom already emits
3299
+ * {@code transport:connected} once that returns. Emitting here too
3300
+ * would double-fire on every initial connect.
3301
+ * - Each callback bails out early when the Mux is not in an active
3302
+ * state ({@code disconnected} / {@code error}). Listeners are
3303
+ * removed on {@code disconnect()} before the callback can fire,
3304
+ * so this guard is mainly defense-in-depth against any in-flight
3305
+ * callback that lands during teardown — but having it at the top
3306
+ * means we never emit a misleading {@code transport:connected}
3307
+ * from a Mux that has already torn down.
3308
+ */
3309
+ buildConnectionListener() {
3310
+ const isInactive = () => this.status === "disconnected" || this.status === "error";
3311
+ return {
3312
+ onConnect: (url) => {
3313
+ if (isInactive()) return;
3314
+ logger.debug("Mux", "Connected to relay:", url);
3315
+ },
3316
+ onDisconnect: (url, reason) => {
3317
+ logger.debug("Mux", "Disconnected from relay:", url, "reason:", reason);
3318
+ },
3319
+ onReconnecting: (url, attempt) => {
3320
+ if (isInactive()) return;
3321
+ logger.debug("Mux", "Reconnecting to relay:", url, "attempt:", attempt);
3322
+ if (!this.usingSharedClient) {
3323
+ this.emitEvent({ type: "transport:reconnecting", timestamp: Date.now() });
3324
+ }
3325
+ },
3326
+ onReconnected: (url) => {
3327
+ if (isInactive()) return;
3328
+ logger.debug("Mux", "Reconnected to relay:", url);
3329
+ if (!this.usingSharedClient) {
3330
+ this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3331
+ }
3332
+ this.updateSubscriptions().catch((err) => {
3333
+ logger.error("Mux", "Failed to re-subscribe after reconnect:", err);
3334
+ });
3335
+ }
3336
+ };
3337
+ }
3338
+ /**
3339
+ * Re-attach to a freshly-created shared NostrClient.
3340
+ *
3341
+ * Call this after the host (e.g. {@link NostrTransportProvider}) has
3342
+ * recreated its NostrClient — typically because the wallet's active
3343
+ * identity changed and the SDK's NostrClient does not support
3344
+ * changing identity at runtime. The previous client has already
3345
+ * been disconnected by the host, so its server-side subscriptions
3346
+ * are gone — we just adopt the new client and re-issue our own.
3347
+ *
3348
+ * The caller is responsible for ordering: by the time rebind runs,
3349
+ * the host transport's new NostrClient must already be created and
3350
+ * connected. In Sphere this is guaranteed because we await
3351
+ * {@code transport.setIdentity()} before calling rebind.
3352
+ *
3353
+ * Returns silently in two cases that are not caller errors:
3354
+ * - the Mux owns its own client (not sharing) — nothing to rebind
3355
+ * - the shared client reference hasn't changed (rebind is a no-op)
3356
+ *
3357
+ * Throws otherwise (rather than silently no-op'ing) so a wiring
3358
+ * mistake — for instance, calling rebind before the host's new
3359
+ * client is ready — surfaces immediately instead of leaving the
3360
+ * Mux pinned to a stale client.
3361
+ */
3362
+ async rebindToSharedClient() {
3363
+ if (!this.usingSharedClient) return;
3364
+ if (!this.sharedNostrClientGetter) return;
3365
+ const newClient = this.sharedNostrClientGetter();
3366
+ if (!newClient) {
3367
+ throw new SphereError(
3368
+ "rebindToSharedClient: shared client getter returned null. The host transport must finish (re)creating its NostrClient before rebind is called.",
3369
+ "TRANSPORT_ERROR"
3370
+ );
3371
+ }
3372
+ if (this.nostrClient === newClient) return;
3373
+ if (!newClient.isConnected()) {
3374
+ throw new SphereError(
3375
+ "rebindToSharedClient: new shared client is not connected. Await transport.setIdentity() / transport.connect() before rebinding.",
3376
+ "TRANSPORT_ERROR"
3377
+ );
3378
+ }
3379
+ if (this.nostrClient && this.connectionListener && this.nostrClient !== newClient) {
3380
+ try {
3381
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3382
+ } catch {
3383
+ }
3384
+ }
3385
+ this.nostrClient = newClient;
3386
+ this.walletSubscriptionId = null;
3387
+ this.chatSubscriptionId = null;
3388
+ this.chatEoseFired = false;
3389
+ this.connectionListener = this.buildConnectionListener();
3390
+ this.nostrClient.addConnectionListener(this.connectionListener);
3391
+ if (this.addresses.size > 0) {
3392
+ await this.updateSubscriptions();
3393
+ }
3394
+ }
3227
3395
  /**
3228
3396
  * One-shot fetch of pending events from the relay.
3229
3397
  * Creates a temporary subscription, waits for EOSE (or timeout),
@@ -3352,8 +3520,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3352
3520
  this.nostrClient.unsubscribe(this.chatSubscriptionId);
3353
3521
  this.chatSubscriptionId = null;
3354
3522
  }
3355
- this.lastWalletEventAt = Date.now();
3356
- this.lastChatEventAt = Date.now();
3357
3523
  if (this.addresses.size === 0) return;
3358
3524
  const allPubkeys = [];
3359
3525
  for (const entry of this.addresses.values()) {
@@ -3440,25 +3606,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3440
3606
  }
3441
3607
  });
3442
3608
  logger.debug("Mux", `updateSubscriptions: walletSub=${this.walletSubscriptionId} chatSub=${this.chatSubscriptionId}`);
3443
- this.startHealthCheck();
3444
- }
3445
- startHealthCheck() {
3446
- if (this.healthCheckTimer) return;
3447
- this.healthCheckTimer = setInterval(() => {
3448
- if (!this.isConnected()) return;
3449
- const chatElapsed = Date.now() - this.lastChatEventAt;
3450
- const walletElapsed = Date.now() - this.lastWalletEventAt;
3451
- const needResubscribe = chatElapsed > 6e4 || walletElapsed > 3e5;
3452
- if (needResubscribe) {
3453
- const reason = chatElapsed > 6e4 ? `No chat events for ${Math.round(chatElapsed / 1e3)}s` : `No wallet events for ${Math.round(walletElapsed / 1e3)}s`;
3454
- logger.warn("Mux", `${reason} \u2014 re-subscribing`);
3455
- this.lastChatEventAt = Date.now();
3456
- this.lastWalletEventAt = Date.now();
3457
- this.updateSubscriptions().catch((err) => {
3458
- logger.warn("Mux", "Health check re-subscription failed:", err);
3459
- });
3460
- }
3461
- }, 3e4);
3462
3609
  }
3463
3610
  /**
3464
3611
  * Schedule a re-subscription after a relay-initiated subscription closure.
@@ -3519,12 +3666,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3519
3666
  }
3520
3667
  }
3521
3668
  }
3522
- if (event.kind !== EventKinds2.GIFT_WRAP) {
3523
- this.lastWalletEventAt = Date.now();
3524
- }
3525
- if (event.kind === EventKinds2.GIFT_WRAP) {
3526
- this.lastChatEventAt = Date.now();
3527
- }
3528
3669
  try {
3529
3670
  if (event.kind === EventKinds2.GIFT_WRAP) {
3530
3671
  await this.routeGiftWrap(event);
@@ -28412,6 +28553,9 @@ var Sphere = class _Sphere {
28412
28553
  this._transport.setFallbackSince(fallbackTs);
28413
28554
  }
28414
28555
  await this._transport.setIdentity(this._identity);
28556
+ if (this._transportMux && typeof this._transportMux.rebindToSharedClient === "function") {
28557
+ await this._transportMux.rebindToSharedClient();
28558
+ }
28415
28559
  this.emitEvent("identity:changed", {
28416
28560
  l1Address: this._identity.l1Address,
28417
28561
  directAddress: this._identity.directAddress,
@@ -28622,7 +28766,12 @@ var Sphere = class _Sphere {
28622
28766
  this._transportMux = new MultiAddressTransportMux({
28623
28767
  relays: nostrTransport.getConfiguredRelays(),
28624
28768
  createWebSocket: nostrTransport.getWebSocketFactory(),
28625
- storage: nostrTransport.getStorageAdapter() ?? void 0
28769
+ storage: nostrTransport.getStorageAdapter() ?? void 0,
28770
+ // #123: share the original transport's NostrClient instead of
28771
+ // opening a second WebSocket per relay. Pass a getter so the
28772
+ // Mux resolves it at connect-time (after the transport finishes
28773
+ // its own connect()).
28774
+ sharedNostrClient: typeof nostrTransport.getNostrClient === "function" ? () => nostrTransport.getNostrClient() : void 0
28626
28775
  });
28627
28776
  await this._transportMux.connect();
28628
28777
  if (typeof nostrTransport.suppressSubscriptions === "function") {