starknet 10.6.6 → 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,15 @@
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
+
7
+ ## [10.6.7](https://github.com/starknet-io/starknet.js/compare/v10.6.6...v10.6.7) (2026-08-06)
8
+
9
+ ### Bug Fixes
10
+
11
+ - **ws:** settle in-flight requests when the connection drops ([8eae9c9](https://github.com/starknet-io/starknet.js/commit/8eae9c9457befe6e0c3f4fd30a56025fe5455fd8))
12
+
1
13
  ## [10.6.6](https://github.com/starknet-io/starknet.js/compare/v10.6.5...v10.6.6) (2026-08-05)
2
14
 
3
15
  ### 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.
@@ -5151,6 +5161,17 @@ declare class WebSocketChannel {
5151
5161
  private reconnectTimeoutId;
5152
5162
  private reconnectStabilityTimeoutId;
5153
5163
  private requestQueue;
5164
+ /** Abort handles for the requests currently on the wire, one per pending `sendReceive`. */
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;
5154
5175
  private events;
5155
5176
  private openListener;
5156
5177
  private closeListener;
@@ -5226,6 +5247,15 @@ declare class WebSocketChannel {
5226
5247
  * @returns {Promise<boolean>} A Promise that resolves with `true` if the unsubscription was successful.
5227
5248
  */
5228
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;
5229
5259
  /**
5230
5260
  * Returns a Promise that resolves when a specific subscription is successfully unsubscribed.
5231
5261
  * @param {SUBSCRIPTION_ID} targetId - The ID of the subscription to wait for.
@@ -5253,6 +5283,15 @@ declare class WebSocketChannel {
5253
5283
  * could rescue them. Settling them here is the only way out.
5254
5284
  */
5255
5285
  private _rejectRequestQueue;
5286
+ /**
5287
+ * Settle every request already on the wire.
5288
+ *
5289
+ * The counterpart of `_rejectRequestQueue`, for requests past the queue. Their only other
5290
+ * exit is the `requestTimeout` timer, so without this the caller waits the whole timeout —
5291
+ * 60s by default — for a reply that can no longer arrive, and that pending timer keeps the
5292
+ * Node event loop alive for just as long.
5293
+ */
5294
+ private _rejectInFlight;
5256
5295
  private _restoreSubscriptions;
5257
5296
  /**
5258
5297
  * Reset the reconnection attempt counter, but only once the current connection has
@@ -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
 
@@ -12618,6 +12644,17 @@ ${indent}}` : "}";
12618
12644
  // resetting the reconnection attempt counter.
12619
12645
  reconnectStabilityTimeoutId = null;
12620
12646
  requestQueue = [];
12647
+ /** Abort handles for the requests currently on the wire, one per pending `sendReceive`. */
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();
12621
12658
  events = new EventEmitter();
12622
12659
  openListener = (ev) => {
12623
12660
  this.scheduleReconnectAttemptsReset();
@@ -12701,12 +12738,19 @@ ${indent}}` : "}";
12701
12738
  });
12702
12739
  }
12703
12740
  const sendId = this.send(method, params);
12741
+ const socket = this.websocket;
12704
12742
  return new Promise((resolve, reject) => {
12705
- let timeoutId;
12706
- if (!this.websocket || this.websocket.readyState !== ws_default.OPEN) {
12743
+ if (socket.readyState !== ws_default.OPEN) {
12707
12744
  reject(new WebSocketNotConnectedError("WebSocket not available or not connected."));
12708
12745
  return;
12709
12746
  }
12747
+ let timeoutId;
12748
+ const settle = () => {
12749
+ clearTimeout(timeoutId);
12750
+ socket.removeEventListener("message", messageHandler);
12751
+ socket.removeEventListener("error", errorHandler);
12752
+ this.inFlight.delete(abort);
12753
+ };
12710
12754
  const messageHandler = (event) => {
12711
12755
  if (!isString(event.data)) {
12712
12756
  logger.warn("WebSocket received non-string message data:", event.data);
@@ -12721,34 +12765,35 @@ ${indent}}` : "}";
12721
12765
  );
12722
12766
  return;
12723
12767
  }
12724
- if (message.id === sendId) {
12725
- clearTimeout(timeoutId);
12726
- this.websocket.removeEventListener("message", messageHandler);
12727
- this.websocket.removeEventListener("error", errorHandler);
12728
- if ("result" in message) {
12729
- resolve(message.result);
12730
- } else {
12731
- reject(
12732
- new Error(`Error on ${method} (id: ${sendId}): ${JSON.stringify(message.error)}`)
12733
- );
12734
- }
12768
+ if (message.id !== sendId) return;
12769
+ settle();
12770
+ if ("result" in message) {
12771
+ resolve(message.result);
12772
+ } else {
12773
+ reject(new Error(`Error on ${method} (id: ${sendId}): ${JSON.stringify(message.error)}`));
12735
12774
  }
12736
12775
  };
12737
12776
  const errorHandler = (event) => {
12738
- clearTimeout(timeoutId);
12739
- this.websocket.removeEventListener("message", messageHandler);
12740
- this.websocket.removeEventListener("error", errorHandler);
12777
+ settle();
12741
12778
  reject(
12742
12779
  new Error(
12743
12780
  `WebSocket error during ${method} (id: ${sendId}): ${event.type || "Unknown error"}`
12744
12781
  )
12745
12782
  );
12746
12783
  };
12747
- this.websocket.addEventListener("message", messageHandler);
12748
- this.websocket.addEventListener("error", errorHandler);
12784
+ const abort = (reason) => {
12785
+ settle();
12786
+ reject(
12787
+ new WebSocketNotConnectedError(
12788
+ `Request ${method} (id: ${sendId}) went unanswered: ${reason}`
12789
+ )
12790
+ );
12791
+ };
12792
+ socket.addEventListener("message", messageHandler);
12793
+ socket.addEventListener("error", errorHandler);
12794
+ this.inFlight.add(abort);
12749
12795
  timeoutId = setTimeout(() => {
12750
- this.websocket.removeEventListener("message", messageHandler);
12751
- this.websocket.removeEventListener("error", errorHandler);
12796
+ settle();
12752
12797
  reject(
12753
12798
  new TimeoutError(
12754
12799
  `Request ${method} (id: ${sendId}) timed out after ${this.requestTimeout}ms`
@@ -12805,6 +12850,8 @@ ${indent}}` : "}";
12805
12850
  this.userInitiatedClose = true;
12806
12851
  this.isReconnecting = false;
12807
12852
  this._rejectRequestQueue("the connection was closed by the user");
12853
+ this._rejectInFlight("the connection was closed by the user");
12854
+ this._rejectUnsubscribeWaiters("the connection was closed by the user");
12808
12855
  this.websocket.close(code, reason);
12809
12856
  }
12810
12857
  /**
@@ -12829,14 +12876,48 @@ ${indent}}` : "}";
12829
12876
  * @returns {Promise<boolean>} A Promise that resolves with `true` if the unsubscription was successful.
12830
12877
  */
12831
12878
  async unsubscribe(subscriptionId) {
12832
- const status = await this.sendReceive("starknet_unsubscribe", {
12833
- subscription_id: subscriptionId
12834
- });
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
+ }
12835
12888
  if (status) {
12836
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
+ );
12837
12896
  }
12838
12897
  return status;
12839
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
+ }
12840
12921
  /**
12841
12922
  * Returns a Promise that resolves when a specific subscription is successfully unsubscribed.
12842
12923
  * @param {SUBSCRIPTION_ID} targetId - The ID of the subscription to wait for.
@@ -12848,14 +12929,10 @@ ${indent}}` : "}";
12848
12929
  * ```
12849
12930
  */
12850
12931
  waitForUnsubscription(targetId) {
12851
- return new Promise((resolve) => {
12852
- const listener = (unsubId) => {
12853
- if (unsubId === targetId) {
12854
- this.events.off("unsubscribe", listener);
12855
- resolve();
12856
- }
12857
- };
12858
- 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);
12859
12936
  });
12860
12937
  }
12861
12938
  /**
@@ -12896,6 +12973,21 @@ ${indent}}` : "}";
12896
12973
  reject(new WebSocketNotConnectedError(`Request ${method} was never sent: ${reason}`));
12897
12974
  });
12898
12975
  }
12976
+ /**
12977
+ * Settle every request already on the wire.
12978
+ *
12979
+ * The counterpart of `_rejectRequestQueue`, for requests past the queue. Their only other
12980
+ * exit is the `requestTimeout` timer, so without this the caller waits the whole timeout —
12981
+ * 60s by default — for a reply that can no longer arrive, and that pending timer keeps the
12982
+ * Node event loop alive for just as long.
12983
+ */
12984
+ _rejectInFlight(reason) {
12985
+ if (this.inFlight.size === 0) return;
12986
+ const pending = Array.from(this.inFlight);
12987
+ this.inFlight.clear();
12988
+ logger.info(`WebSocket: Rejecting ${pending.length} in-flight request(s). Reason: ${reason}.`);
12989
+ pending.forEach((abort) => abort(reason));
12990
+ }
12899
12991
  async _restoreSubscriptions() {
12900
12992
  const oldSubscriptions = Array.from(this.activeSubscriptions.values());
12901
12993
  this.activeSubscriptions.clear();
@@ -12907,6 +12999,7 @@ ${indent}}` : "}";
12907
12999
  logger.info(`Subscription ${sub.method} restored with new ID: ${newSubId}`);
12908
13000
  } catch (error) {
12909
13001
  logger.error(`Failed to restore subscription ${sub.method}:`, error);
13002
+ sub._markClosed();
12910
13003
  }
12911
13004
  });
12912
13005
  await Promise.all(restorePromises);
@@ -12949,18 +13042,24 @@ ${indent}}` : "}";
12949
13042
  `WebSocket: Connection lost. Attempting to reconnect... (${this.reconnectAttempts}/${this.reconnectOptions.retries})`
12950
13043
  );
12951
13044
  this.reconnect();
13045
+ let attemptSettled = false;
13046
+ const scheduleRetry = () => {
13047
+ if (attemptSettled || !this.isReconnecting) return;
13048
+ attemptSettled = true;
13049
+ const delay = this.reconnectOptions.exponential ? this.reconnectOptions.delay * 2 ** (this.reconnectAttempts - 1) : this.reconnectOptions.delay;
13050
+ logger.info(`WebSocket: Reconnect attempt failed. Retrying in ${delay}ms.`);
13051
+ this.reconnectTimeoutId = setTimeout(tryReconnect, delay);
13052
+ };
12952
13053
  this.websocket.onopen = async () => {
12953
13054
  logger.info("WebSocket: Reconnection successful.");
13055
+ attemptSettled = true;
12954
13056
  this.isReconnecting = false;
12955
13057
  await this._restoreSubscriptions();
12956
13058
  this._processRequestQueue();
12957
13059
  this.events.emit("open", new Event("open"));
12958
13060
  };
12959
- this.websocket.onerror = () => {
12960
- const delay = this.reconnectOptions.exponential ? this.reconnectOptions.delay * 2 ** (this.reconnectAttempts - 1) : this.reconnectOptions.delay;
12961
- logger.info(`WebSocket: Reconnect attempt failed. Retrying in ${delay}ms.`);
12962
- this.reconnectTimeoutId = setTimeout(tryReconnect, delay);
12963
- };
13061
+ this.websocket.onerror = scheduleRetry;
13062
+ this.websocket.addEventListener("close", scheduleRetry);
12964
13063
  };
12965
13064
  tryReconnect();
12966
13065
  }
@@ -12969,6 +13068,8 @@ ${indent}}` : "}";
12969
13068
  this.websocket.removeEventListener("close", this.closeListener);
12970
13069
  this.websocket.removeEventListener("message", this.messageListener);
12971
13070
  this.websocket.removeEventListener("error", this.errorListener);
13071
+ this._rejectInFlight("the connection was closed");
13072
+ this._rejectUnsubscribeWaiters("the connection was closed");
12972
13073
  this.events.emit("close", ev);
12973
13074
  if (!this.userInitiatedClose) {
12974
13075
  this._startReconnect();
@@ -12990,9 +13091,16 @@ ${indent}}` : "}";
12990
13091
  if (subscription) {
12991
13092
  subscription._handleEvent(result);
12992
13093
  } else {
12993
- logger.warn(
12994
- `WebSocketChannel: Received event for untracked subscription ID: ${subscription_id}.`
12995
- );
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
+ });
12996
13104
  }
12997
13105
  }
12998
13106
  logger.debug("onMessageProxy:", event.data);