starknet 10.6.7 → 10.6.8

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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [10.6.8](https://github.com/starknet-io/starknet.js/compare/v10.6.7...v10.6.8) (2026-08-07)
2
+
3
+ ### Bug Fixes
4
+
5
+ - **ws:** close the subscription lifecycle races ([c242c76](https://github.com/starknet-io/starknet.js/commit/c242c76ab73f508ecd454d1d7eb2fde8728c4f9d))
6
+
1
7
  ## [10.6.7](https://github.com/starknet-io/starknet.js/compare/v10.6.6...v10.6.7) (2026-08-06)
2
8
 
3
9
  ### Bug Fixes
package/dist/index.d.ts CHANGED
@@ -4962,6 +4962,7 @@ declare class Subscription<T = any> {
4962
4962
  private maxBufferSize;
4963
4963
  private handler;
4964
4964
  private _isClosed;
4965
+ private pendingUnsubscribe;
4965
4966
  /**
4966
4967
  * @internal
4967
4968
  * @param options - Subscription configuration options
@@ -4972,6 +4973,15 @@ declare class Subscription<T = any> {
4972
4973
  * @returns {boolean} `true` if unsubscribed, `false` otherwise.
4973
4974
  */
4974
4975
  get isClosed(): boolean;
4976
+ /**
4977
+ * Closes the subscription locally, without contacting the node.
4978
+ *
4979
+ * Used when the channel knows the subscription is gone and cannot be recovered — a
4980
+ * re-subscribe refused after a reconnection, for instance. Without it the object would keep
4981
+ * reporting itself as open while no event could ever reach its handler again.
4982
+ * @internal
4983
+ */
4984
+ _markClosed(): void;
4975
4985
  /**
4976
4986
  * Internal method to handle incoming events from the WebSocket channel.
4977
4987
  * If a handler is attached, it's invoked immediately. Otherwise, the event is buffered.
@@ -5153,6 +5163,15 @@ declare class WebSocketChannel {
5153
5163
  private requestQueue;
5154
5164
  /** Abort handles for the requests currently on the wire, one per pending `sendReceive`. */
5155
5165
  private inFlight;
5166
+ /**
5167
+ * Callers blocked in `waitForUnsubscription`, keyed by subscription id.
5168
+ *
5169
+ * Held here rather than as `unsubscribe` event listeners because that event only ever
5170
+ * announces success: a waiter attached to it cannot learn that the node refused the
5171
+ * unsubscribe or that the connection went away, and would wait forever with no timeout of
5172
+ * its own to fall back on.
5173
+ */
5174
+ private unsubscribeWaiters;
5156
5175
  private events;
5157
5176
  private openListener;
5158
5177
  private closeListener;
@@ -5228,6 +5247,15 @@ declare class WebSocketChannel {
5228
5247
  * @returns {Promise<boolean>} A Promise that resolves with `true` if the unsubscription was successful.
5229
5248
  */
5230
5249
  unsubscribe(subscriptionId: SUBSCRIPTION_ID): Promise<boolean>;
5250
+ /** Settles every caller waiting on one subscription id: resolved, or rejected with `error`. */
5251
+ private _settleUnsubscribeWaiters;
5252
+ /**
5253
+ * Rejects every caller still waiting on any subscription.
5254
+ *
5255
+ * Once the connection is gone, no unsubscribe can be observed on it: a reconnection restores
5256
+ * each subscription under a fresh id, so the id being waited on will never be announced.
5257
+ */
5258
+ private _rejectUnsubscribeWaiters;
5231
5259
  /**
5232
5260
  * Returns a Promise that resolves when a specific subscription is successfully unsubscribed.
5233
5261
  * @param {SUBSCRIPTION_ID} targetId - The ID of the subscription to wait for.
@@ -12516,6 +12516,8 @@ ${indent}}` : "}";
12516
12516
  maxBufferSize;
12517
12517
  handler = null;
12518
12518
  _isClosed = false;
12519
+ // The unsubscribe request currently on the wire, shared by concurrent callers.
12520
+ pendingUnsubscribe = null;
12519
12521
  /**
12520
12522
  * @internal
12521
12523
  * @param options - Subscription configuration options
@@ -12534,6 +12536,20 @@ ${indent}}` : "}";
12534
12536
  get isClosed() {
12535
12537
  return this._isClosed;
12536
12538
  }
12539
+ /**
12540
+ * Closes the subscription locally, without contacting the node.
12541
+ *
12542
+ * Used when the channel knows the subscription is gone and cannot be recovered — a
12543
+ * re-subscribe refused after a reconnection, for instance. Without it the object would keep
12544
+ * reporting itself as open while no event could ever reach its handler again.
12545
+ * @internal
12546
+ */
12547
+ _markClosed() {
12548
+ if (this._isClosed) return;
12549
+ this._isClosed = true;
12550
+ this.events.emit("unsubscribe", void 0);
12551
+ this.events.clear();
12552
+ }
12537
12553
  /**
12538
12554
  * Internal method to handle incoming events from the WebSocket channel.
12539
12555
  * If a handler is attached, it's invoked immediately. Otherwise, the event is buffered.
@@ -12580,14 +12596,24 @@ ${indent}}` : "}";
12580
12596
  if (this._isClosed) {
12581
12597
  return true;
12582
12598
  }
12583
- const success = await this.channel.unsubscribe(this.id);
12584
- if (success) {
12585
- this._isClosed = true;
12586
- this.channel.removeSubscription(this.id);
12587
- this.events.emit("unsubscribe", void 0);
12588
- this.events.clear();
12599
+ if (this.pendingUnsubscribe) {
12600
+ return this.pendingUnsubscribe;
12589
12601
  }
12590
- return success;
12602
+ this.pendingUnsubscribe = (async () => {
12603
+ try {
12604
+ const success = await this.channel.unsubscribe(this.id);
12605
+ if (success) {
12606
+ this._isClosed = true;
12607
+ this.channel.removeSubscription(this.id);
12608
+ this.events.emit("unsubscribe", void 0);
12609
+ this.events.clear();
12610
+ }
12611
+ return success;
12612
+ } finally {
12613
+ this.pendingUnsubscribe = null;
12614
+ }
12615
+ })();
12616
+ return this.pendingUnsubscribe;
12591
12617
  }
12592
12618
  };
12593
12619
 
@@ -12620,6 +12646,15 @@ ${indent}}` : "}";
12620
12646
  requestQueue = [];
12621
12647
  /** Abort handles for the requests currently on the wire, one per pending `sendReceive`. */
12622
12648
  inFlight = /* @__PURE__ */ new Set();
12649
+ /**
12650
+ * Callers blocked in `waitForUnsubscription`, keyed by subscription id.
12651
+ *
12652
+ * Held here rather than as `unsubscribe` event listeners because that event only ever
12653
+ * announces success: a waiter attached to it cannot learn that the node refused the
12654
+ * unsubscribe or that the connection went away, and would wait forever with no timeout of
12655
+ * its own to fall back on.
12656
+ */
12657
+ unsubscribeWaiters = /* @__PURE__ */ new Map();
12623
12658
  events = new EventEmitter();
12624
12659
  openListener = (ev) => {
12625
12660
  this.scheduleReconnectAttemptsReset();
@@ -12816,6 +12851,7 @@ ${indent}}` : "}";
12816
12851
  this.isReconnecting = false;
12817
12852
  this._rejectRequestQueue("the connection was closed by the user");
12818
12853
  this._rejectInFlight("the connection was closed by the user");
12854
+ this._rejectUnsubscribeWaiters("the connection was closed by the user");
12819
12855
  this.websocket.close(code, reason);
12820
12856
  }
12821
12857
  /**
@@ -12840,14 +12876,48 @@ ${indent}}` : "}";
12840
12876
  * @returns {Promise<boolean>} A Promise that resolves with `true` if the unsubscription was successful.
12841
12877
  */
12842
12878
  async unsubscribe(subscriptionId) {
12843
- const status = await this.sendReceive("starknet_unsubscribe", {
12844
- subscription_id: subscriptionId
12845
- });
12879
+ let status;
12880
+ try {
12881
+ status = await this.sendReceive("starknet_unsubscribe", {
12882
+ subscription_id: subscriptionId
12883
+ });
12884
+ } catch (error) {
12885
+ this._settleUnsubscribeWaiters(subscriptionId, error);
12886
+ throw error;
12887
+ }
12846
12888
  if (status) {
12847
12889
  this.events.emit("unsubscribe", subscriptionId);
12890
+ this._settleUnsubscribeWaiters(subscriptionId);
12891
+ } else {
12892
+ this._settleUnsubscribeWaiters(
12893
+ subscriptionId,
12894
+ new Error(`Node refused to unsubscribe subscription ${subscriptionId}`)
12895
+ );
12848
12896
  }
12849
12897
  return status;
12850
12898
  }
12899
+ /** Settles every caller waiting on one subscription id: resolved, or rejected with `error`. */
12900
+ _settleUnsubscribeWaiters(subscriptionId, error) {
12901
+ const waiters = this.unsubscribeWaiters.get(subscriptionId);
12902
+ if (!waiters) return;
12903
+ this.unsubscribeWaiters.delete(subscriptionId);
12904
+ waiters.forEach((waiter) => error ? waiter.reject(error) : waiter.resolve());
12905
+ }
12906
+ /**
12907
+ * Rejects every caller still waiting on any subscription.
12908
+ *
12909
+ * Once the connection is gone, no unsubscribe can be observed on it: a reconnection restores
12910
+ * each subscription under a fresh id, so the id being waited on will never be announced.
12911
+ */
12912
+ _rejectUnsubscribeWaiters(reason) {
12913
+ if (this.unsubscribeWaiters.size === 0) return;
12914
+ Array.from(this.unsubscribeWaiters.keys()).forEach(
12915
+ (id) => this._settleUnsubscribeWaiters(
12916
+ id,
12917
+ new WebSocketNotConnectedError(`Subscription ${id} was never unsubscribed: ${reason}`)
12918
+ )
12919
+ );
12920
+ }
12851
12921
  /**
12852
12922
  * Returns a Promise that resolves when a specific subscription is successfully unsubscribed.
12853
12923
  * @param {SUBSCRIPTION_ID} targetId - The ID of the subscription to wait for.
@@ -12859,14 +12929,10 @@ ${indent}}` : "}";
12859
12929
  * ```
12860
12930
  */
12861
12931
  waitForUnsubscription(targetId) {
12862
- return new Promise((resolve) => {
12863
- const listener = (unsubId) => {
12864
- if (unsubId === targetId) {
12865
- this.events.off("unsubscribe", listener);
12866
- resolve();
12867
- }
12868
- };
12869
- this.events.on("unsubscribe", listener);
12932
+ return new Promise((resolve, reject) => {
12933
+ const waiters = this.unsubscribeWaiters.get(targetId) ?? /* @__PURE__ */ new Set();
12934
+ waiters.add({ resolve, reject });
12935
+ this.unsubscribeWaiters.set(targetId, waiters);
12870
12936
  });
12871
12937
  }
12872
12938
  /**
@@ -12933,6 +12999,7 @@ ${indent}}` : "}";
12933
12999
  logger.info(`Subscription ${sub.method} restored with new ID: ${newSubId}`);
12934
13000
  } catch (error) {
12935
13001
  logger.error(`Failed to restore subscription ${sub.method}:`, error);
13002
+ sub._markClosed();
12936
13003
  }
12937
13004
  });
12938
13005
  await Promise.all(restorePromises);
@@ -13002,6 +13069,7 @@ ${indent}}` : "}";
13002
13069
  this.websocket.removeEventListener("message", this.messageListener);
13003
13070
  this.websocket.removeEventListener("error", this.errorListener);
13004
13071
  this._rejectInFlight("the connection was closed");
13072
+ this._rejectUnsubscribeWaiters("the connection was closed");
13005
13073
  this.events.emit("close", ev);
13006
13074
  if (!this.userInitiatedClose) {
13007
13075
  this._startReconnect();
@@ -13023,9 +13091,16 @@ ${indent}}` : "}";
13023
13091
  if (subscription) {
13024
13092
  subscription._handleEvent(result);
13025
13093
  } else {
13026
- logger.warn(
13027
- `WebSocketChannel: Received event for untracked subscription ID: ${subscription_id}.`
13028
- );
13094
+ queueMicrotask(() => {
13095
+ const registered = this.activeSubscriptions.get(subscription_id);
13096
+ if (registered) {
13097
+ registered._handleEvent(result);
13098
+ } else {
13099
+ logger.warn(
13100
+ `WebSocketChannel: Received event for untracked subscription ID: ${subscription_id}.`
13101
+ );
13102
+ }
13103
+ });
13029
13104
  }
13030
13105
  }
13031
13106
  logger.debug("onMessageProxy:", event.data);