@lifi/perps-sdk 1.5.4 → 1.5.6

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.
Files changed (122) hide show
  1. package/dist/cjs/registry/assetRegistry.d.ts +1 -1
  2. package/dist/cjs/registry/assetRegistry.d.ts.map +1 -1
  3. package/dist/cjs/registry/assetRegistry.js +2 -2
  4. package/dist/cjs/registry/assetRegistry.js.map +1 -1
  5. package/dist/cjs/registry/marketRegistry.d.ts +1 -1
  6. package/dist/cjs/registry/marketRegistry.d.ts.map +1 -1
  7. package/dist/cjs/registry/marketRegistry.js +2 -2
  8. package/dist/cjs/registry/marketRegistry.js.map +1 -1
  9. package/dist/cjs/registry/referenceDataRegistry.d.ts +1 -3
  10. package/dist/cjs/registry/referenceDataRegistry.d.ts.map +1 -1
  11. package/dist/cjs/registry/referenceDataRegistry.js +3 -14
  12. package/dist/cjs/registry/referenceDataRegistry.js.map +1 -1
  13. package/dist/cjs/services/getMarket.d.ts.map +1 -1
  14. package/dist/cjs/services/getMarket.js +9 -1
  15. package/dist/cjs/services/getMarket.js.map +1 -1
  16. package/dist/cjs/transport/fetchWithRetry.d.ts +1 -0
  17. package/dist/cjs/transport/fetchWithRetry.d.ts.map +1 -1
  18. package/dist/cjs/transport/fetchWithRetry.js +8 -1
  19. package/dist/cjs/transport/fetchWithRetry.js.map +1 -1
  20. package/dist/cjs/transport/request.d.ts.map +1 -1
  21. package/dist/cjs/transport/request.js +3 -0
  22. package/dist/cjs/transport/request.js.map +1 -1
  23. package/dist/cjs/utils/accountSummary.d.ts.map +1 -1
  24. package/dist/cjs/utils/accountSummary.js +2 -1
  25. package/dist/cjs/utils/accountSummary.js.map +1 -1
  26. package/dist/cjs/utils/calculations.d.ts +1 -1
  27. package/dist/cjs/utils/calculations.d.ts.map +1 -1
  28. package/dist/cjs/utils/calculations.js +7 -1
  29. package/dist/cjs/utils/calculations.js.map +1 -1
  30. package/dist/cjs/utils/sleep.d.ts +1 -1
  31. package/dist/cjs/utils/sleep.d.ts.map +1 -1
  32. package/dist/cjs/utils/sleep.js +16 -1
  33. package/dist/cjs/utils/sleep.js.map +1 -1
  34. package/dist/cjs/version.d.ts +1 -1
  35. package/dist/cjs/version.js +1 -1
  36. package/dist/cjs/websocket/ReconnectingWebSocket.d.ts.map +1 -1
  37. package/dist/cjs/websocket/ReconnectingWebSocket.js +4 -0
  38. package/dist/cjs/websocket/ReconnectingWebSocket.js.map +1 -1
  39. package/dist/cjs/websocket/WsProviderBase.d.ts.map +1 -1
  40. package/dist/cjs/websocket/WsProviderBase.js +9 -1
  41. package/dist/cjs/websocket/WsProviderBase.js.map +1 -1
  42. package/dist/esm/registry/assetRegistry.d.ts +1 -1
  43. package/dist/esm/registry/assetRegistry.d.ts.map +1 -1
  44. package/dist/esm/registry/assetRegistry.js +2 -2
  45. package/dist/esm/registry/assetRegistry.js.map +1 -1
  46. package/dist/esm/registry/marketRegistry.d.ts +1 -1
  47. package/dist/esm/registry/marketRegistry.d.ts.map +1 -1
  48. package/dist/esm/registry/marketRegistry.js +2 -2
  49. package/dist/esm/registry/marketRegistry.js.map +1 -1
  50. package/dist/esm/registry/referenceDataRegistry.d.ts +5 -11
  51. package/dist/esm/registry/referenceDataRegistry.d.ts.map +1 -1
  52. package/dist/esm/registry/referenceDataRegistry.js +6 -23
  53. package/dist/esm/registry/referenceDataRegistry.js.map +1 -1
  54. package/dist/esm/services/getMarket.d.ts +4 -3
  55. package/dist/esm/services/getMarket.d.ts.map +1 -1
  56. package/dist/esm/services/getMarket.js +13 -4
  57. package/dist/esm/services/getMarket.js.map +1 -1
  58. package/dist/esm/transport/fetchWithRetry.d.ts +11 -1
  59. package/dist/esm/transport/fetchWithRetry.d.ts.map +1 -1
  60. package/dist/esm/transport/fetchWithRetry.js +17 -2
  61. package/dist/esm/transport/fetchWithRetry.js.map +1 -1
  62. package/dist/esm/transport/request.d.ts.map +1 -1
  63. package/dist/esm/transport/request.js +4 -1
  64. package/dist/esm/transport/request.js.map +1 -1
  65. package/dist/esm/utils/accountSummary.d.ts +7 -3
  66. package/dist/esm/utils/accountSummary.d.ts.map +1 -1
  67. package/dist/esm/utils/accountSummary.js +5 -1
  68. package/dist/esm/utils/accountSummary.js.map +1 -1
  69. package/dist/esm/utils/calculations.d.ts +5 -1
  70. package/dist/esm/utils/calculations.d.ts.map +1 -1
  71. package/dist/esm/utils/calculations.js +11 -1
  72. package/dist/esm/utils/calculations.js.map +1 -1
  73. package/dist/esm/utils/sleep.d.ts +3 -2
  74. package/dist/esm/utils/sleep.d.ts.map +1 -1
  75. package/dist/esm/utils/sleep.js +18 -2
  76. package/dist/esm/utils/sleep.js.map +1 -1
  77. package/dist/esm/version.d.ts +1 -1
  78. package/dist/esm/version.js +1 -1
  79. package/dist/esm/websocket/ReconnectingWebSocket.d.ts +7 -4
  80. package/dist/esm/websocket/ReconnectingWebSocket.d.ts.map +1 -1
  81. package/dist/esm/websocket/ReconnectingWebSocket.js +11 -4
  82. package/dist/esm/websocket/ReconnectingWebSocket.js.map +1 -1
  83. package/dist/esm/websocket/WsProviderBase.d.ts.map +1 -1
  84. package/dist/esm/websocket/WsProviderBase.js +14 -1
  85. package/dist/esm/websocket/WsProviderBase.js.map +1 -1
  86. package/dist/esm/websocket/types.d.ts +3 -3
  87. package/dist/types/registry/assetRegistry.d.ts +1 -1
  88. package/dist/types/registry/assetRegistry.d.ts.map +1 -1
  89. package/dist/types/registry/marketRegistry.d.ts +1 -1
  90. package/dist/types/registry/marketRegistry.d.ts.map +1 -1
  91. package/dist/types/registry/referenceDataRegistry.d.ts +5 -11
  92. package/dist/types/registry/referenceDataRegistry.d.ts.map +1 -1
  93. package/dist/types/services/getMarket.d.ts +4 -3
  94. package/dist/types/services/getMarket.d.ts.map +1 -1
  95. package/dist/types/transport/fetchWithRetry.d.ts +11 -1
  96. package/dist/types/transport/fetchWithRetry.d.ts.map +1 -1
  97. package/dist/types/transport/request.d.ts.map +1 -1
  98. package/dist/types/utils/accountSummary.d.ts +7 -3
  99. package/dist/types/utils/accountSummary.d.ts.map +1 -1
  100. package/dist/types/utils/calculations.d.ts +5 -1
  101. package/dist/types/utils/calculations.d.ts.map +1 -1
  102. package/dist/types/utils/sleep.d.ts +3 -2
  103. package/dist/types/utils/sleep.d.ts.map +1 -1
  104. package/dist/types/version.d.ts +1 -1
  105. package/dist/types/websocket/ReconnectingWebSocket.d.ts +7 -4
  106. package/dist/types/websocket/ReconnectingWebSocket.d.ts.map +1 -1
  107. package/dist/types/websocket/WsProviderBase.d.ts.map +1 -1
  108. package/dist/types/websocket/types.d.ts +3 -3
  109. package/package.json +1 -1
  110. package/src/registry/assetRegistry.ts +4 -6
  111. package/src/registry/marketRegistry.ts +4 -6
  112. package/src/registry/referenceDataRegistry.ts +8 -32
  113. package/src/services/getMarket.ts +16 -4
  114. package/src/transport/fetchWithRetry.ts +18 -2
  115. package/src/transport/request.ts +5 -1
  116. package/src/utils/accountSummary.ts +14 -4
  117. package/src/utils/calculations.ts +22 -9
  118. package/src/utils/sleep.ts +19 -3
  119. package/src/version.ts +1 -1
  120. package/src/websocket/ReconnectingWebSocket.ts +11 -4
  121. package/src/websocket/WsProviderBase.ts +14 -1
  122. package/src/websocket/types.ts +3 -3
@@ -44,15 +44,13 @@ export class MarketRegistry extends ReferenceDataRegistry<Market> {
44
44
  return market
45
45
  }
46
46
 
47
- protected fetchItems(bypassHttpCache: boolean): Promise<Market[]> {
47
+ protected fetchItems(): Promise<Market[]> {
48
48
  const url = buildUrl(`${this.client.config.apiUrl}/markets`, {
49
49
  provider: this.provider,
50
50
  })
51
- return request<MarketsResponse>(
52
- this.client.config,
53
- url,
54
- bypassHttpCache ? { cache: 'no-cache' } : {}
55
- ).then((response) => response.markets)
51
+ return request<MarketsResponse>(this.client.config, url).then(
52
+ (response) => response.markets
53
+ )
56
54
  }
57
55
 
58
56
  protected keyOf(market: Market): string {
@@ -1,17 +1,13 @@
1
1
  import type { PerpsSDKClient } from '../types/provider.js'
2
2
 
3
- /** Minimum gap between unknown-id-triggered, HTTP-cache-bypassing refetches. */
4
- const REFRESH_COOLDOWN_MS = 60_000
5
-
6
3
  /**
7
4
  * Per-provider hash index over one of the provider's reference-data lists
8
5
  * (`/markets`, `/assets`), keyed by the list's own primary key.
9
6
  *
10
7
  * NOT a cache: it holds no freshness policy of its own. Every {@link sync}
11
8
  * refetches through the HTTP layer, whose `cache-control` headers decide
12
- * whether the response comes from disk or the network. A lookup miss
13
- * schedules a cooldown-gated background refetch that bypasses the HTTP
14
- * cache — the id may have listed after the cached snapshot.
9
+ * whether the response comes from disk or the network. A lookup miss warns
10
+ * once and returns `undefined`; the next {@link sync} reconciles the index.
15
11
  *
16
12
  * @internal
17
13
  */
@@ -20,7 +16,6 @@ export abstract class ReferenceDataRegistry<T> {
20
16
  private current: readonly T[] = []
21
17
  private inflight: Promise<readonly T[]> | undefined
22
18
  private warnedIds = new Set<string>()
23
- private refreshAfter = 0
24
19
 
25
20
  protected constructor(
26
21
  protected readonly client: PerpsSDKClient,
@@ -28,8 +23,8 @@ export abstract class ReferenceDataRegistry<T> {
28
23
  private readonly kind: string
29
24
  ) {}
30
25
 
31
- /** Fetch the provider's full list, bypassing the HTTP cache when asked. */
32
- protected abstract fetchItems(bypassHttpCache: boolean): Promise<T[]>
26
+ /** Fetch the provider's full list through the HTTP layer. */
27
+ protected abstract fetchItems(): Promise<T[]>
33
28
 
34
29
  /** The item's primary key — what {@link get} is keyed by. */
35
30
  protected abstract keyOf(item: T): string
@@ -41,7 +36,7 @@ export abstract class ReferenceDataRegistry<T> {
41
36
  */
42
37
  sync(): Promise<readonly T[]> {
43
38
  if (!this.inflight) {
44
- this.inflight = this.load(false).finally(() => {
39
+ this.inflight = this.load().finally(() => {
45
40
  this.inflight = undefined
46
41
  })
47
42
  }
@@ -53,10 +48,7 @@ export abstract class ReferenceDataRegistry<T> {
53
48
  return this.current
54
49
  }
55
50
 
56
- /**
57
- * O(1) lookup by primary key. A miss warns once per id and schedules the
58
- * cooldown-gated, cache-bypassing background refetch.
59
- */
51
+ /** O(1) lookup by primary key. A miss warns once per id. */
60
52
  get(id: string): T | undefined {
61
53
  const item = this.index.get(id)
62
54
  if (item !== undefined) {
@@ -66,30 +58,14 @@ export abstract class ReferenceDataRegistry<T> {
66
58
  this.warnedIds.add(id)
67
59
  console.warn(`[${this.provider}] unknown ${this.kind} id '${id}'`)
68
60
  }
69
- this.scheduleRefresh()
70
61
  return undefined
71
62
  }
72
63
 
73
- private async load(bypassHttpCache: boolean): Promise<readonly T[]> {
74
- const items = await this.fetchItems(bypassHttpCache)
64
+ private async load(): Promise<readonly T[]> {
65
+ const items = await this.fetchItems()
75
66
  this.index = new Map(items.map((item) => [this.keyOf(item), item]))
76
67
  this.current = items
77
68
  this.warnedIds.clear()
78
69
  return items
79
70
  }
80
-
81
- private scheduleRefresh(): void {
82
- const now = Date.now()
83
- if (now < this.refreshAfter) {
84
- return
85
- }
86
- // Set before any await so concurrent misses cannot trigger a refetch storm.
87
- this.refreshAfter = now + REFRESH_COOLDOWN_MS
88
- this.load(true).catch((error) =>
89
- console.error(
90
- `[${this.provider}] ${this.kind} registry refresh failed`,
91
- error
92
- )
93
- )
94
- }
95
71
  }
@@ -1,4 +1,6 @@
1
1
  import type { Market, MarketsResponse } from '@lifi/perps-types'
2
+ import { PerpsErrorCode } from '@lifi/perps-types'
3
+ import { PerpsError } from '../errors/PerpsError.js'
2
4
  import { buildUrl, request } from '../transport/request.js'
3
5
  import type { SDKRequestOptions } from '../types/config.js'
4
6
  import type { PerpsSDKClient } from '../types/provider.js'
@@ -21,10 +23,11 @@ export interface GetMarketParams {
21
23
  /**
22
24
  * Get a specific market by its opaque marketId. Requests the single market
23
25
  * from the backend `/markets` route (filtered by `marketIds`) and returns the
24
- * first match; the backend responds 404 (thrown as a {@link PerpsError}) when
25
- * nothing matches.
26
+ * first match; throws a `MarketNotFound` {@link PerpsError} both when the
27
+ * backend responds 404 and when it responds 200 with an empty list, matching
28
+ * `MarketRegistry.require`'s miss behaviour.
26
29
  *
27
- * @throws {PerpsError} On backend (e.g. 404), network, or parsing errors.
30
+ * @throws {PerpsError} On backend (e.g. 404 or empty match), network, or parsing errors.
28
31
  * @example
29
32
  * ```ts
30
33
  * const client = createPerpsClient({ integrator: 'my-app' })
@@ -48,5 +51,14 @@ export async function getMarket(
48
51
  {},
49
52
  options
50
53
  )
51
- return markets[0]
54
+ const market = markets[0]
55
+ if (market === undefined) {
56
+ const error = new PerpsError(
57
+ PerpsErrorCode.MarketNotFound,
58
+ `No ${params.provider} market found for marketId '${params.marketId}'`
59
+ )
60
+ error.tool = params.provider
61
+ throw error
62
+ }
63
+ return market
52
64
  }
@@ -12,12 +12,25 @@ export interface FetchWithRetryOptions {
12
12
  signal?: AbortSignal
13
13
  }
14
14
 
15
+ /**
16
+ * True when `error` is an `AbortSignal` cancellation (the native `fetch` abort
17
+ * rejection) rather than a genuine network failure. An abort is a deliberate
18
+ * caller action, so it is rethrown untouched instead of being retried.
19
+ *
20
+ * @internal
21
+ */
22
+ export function isAbortError(error: unknown): boolean {
23
+ return error instanceof Error && error.name === 'AbortError'
24
+ }
25
+
15
26
  /**
16
27
  * Wrap `fetch` with policy-driven retry. Honors `Retry-After` (when present
17
28
  * and `respectRetryAfter` is true), otherwise falls back to exponential
18
29
  * backoff with full jitter, capped by `maxDelayMs`. Network errors (thrown
19
30
  * `fetch` rejections) are treated as `retry-network` — same retry budget as
20
- * 5xx. An aborted signal short-circuits without further attempts.
31
+ * 5xx. Aborts (a pre-aborted signal, a mid-flight abort rejection, or a signal
32
+ * that fires during a between-retries backoff) reject with the abort reason
33
+ * without any further attempt.
21
34
  *
22
35
  * @public
23
36
  */
@@ -40,6 +53,9 @@ export async function fetchWithRetry(
40
53
  try {
41
54
  response = await fetchImpl(input, mergedInit)
42
55
  } catch (error) {
56
+ if (isAbortError(error)) {
57
+ throw error
58
+ }
43
59
  networkError = error
44
60
  }
45
61
 
@@ -78,7 +94,7 @@ export async function fetchWithRetry(
78
94
 
79
95
  const delayMs = computeDelay(policy, attempt, response)
80
96
  if (delayMs > 0) {
81
- await sleep(delayMs)
97
+ await sleep(delayMs, signal)
82
98
  }
83
99
  attempt++
84
100
  }
@@ -3,7 +3,7 @@ import { PerpsErrorCode } from '@lifi/perps-types'
3
3
  import { PerpsError } from '../errors/PerpsError.js'
4
4
  import type { PerpsBaseConfig, SDKRequestOptions } from '../types/config.js'
5
5
  import { version } from '../version.js'
6
- import { fetchWithRetry } from './fetchWithRetry.js'
6
+ import { fetchWithRetry, isAbortError } from './fetchWithRetry.js'
7
7
  import {
8
8
  LIFI_REQUEST_KEY,
9
9
  LIFI_RETRY_DEFAULTS,
@@ -94,6 +94,10 @@ export async function request<T>(
94
94
  throw error
95
95
  }
96
96
 
97
+ if (isAbortError(error)) {
98
+ throw error
99
+ }
100
+
97
101
  throw new PerpsError(
98
102
  PerpsErrorCode.ServerError,
99
103
  error instanceof Error ? error.message : 'Request failed'
@@ -13,11 +13,15 @@ const sumValueUsd = (balances: AccountResponse['balances']): number =>
13
13
  * positions' locked margin and unrealized PnL:
14
14
  *
15
15
  * - `'free'` — free collateral only; locked margin and unrealized PnL are
16
- * both carried by the positions.
16
+ * both carried by the positions. Available margin is the free collateral
17
+ * as-is; unrealized PnL is not counted toward it.
17
18
  * - `'gross'` — locked margin included, unrealized PnL carried by the
18
- * positions (e.g. spot holdings backing a unified account).
19
+ * positions (e.g. spot holdings backing a unified account). The venue
20
+ * counts unrealized PnL toward buying power, so it is added to available
21
+ * margin on top of the gross collateral.
19
22
  * - `'equity'` — total equity: locked margin AND unrealized PnL included
20
- * (e.g. a venue-reported account value).
23
+ * (e.g. a venue-reported account value). Unrealized PnL is thus already
24
+ * counted toward available margin.
21
25
  *
22
26
  * @public
23
27
  */
@@ -51,9 +55,15 @@ export function summarizeAccount(
51
55
  const equity =
52
56
  semantics === 'equity' ? grossCollateral : grossCollateral + unrealizedPnl
53
57
 
58
+ // Buying power is equity net of locked margin, so unrealized PnL counts
59
+ // toward it. The `'free'` rows exclude uPnL from available margin, holding
60
+ // it purely as free collateral.
61
+ const availableMargin =
62
+ semantics === 'free' ? grossCollateral - marginUsed : equity - marginUsed
63
+
54
64
  return {
55
65
  portfolioValue: (balances + equity).toString(),
56
- availableMargin: (grossCollateral - marginUsed).toString(),
66
+ availableMargin: availableMargin.toString(),
57
67
  marginUsed: marginUsed.toString(),
58
68
  unrealizedPnl: unrealizedPnl.toString(),
59
69
  }
@@ -6,15 +6,17 @@
6
6
  * values for critical financial parameters.
7
7
  */
8
8
 
9
- import type {
10
- FeeTier,
11
- Market,
12
- MarketContext,
13
- OrderbookLevel,
14
- Quote,
15
- QuoteSide,
16
- TradeType,
9
+ import {
10
+ type FeeTier,
11
+ type Market,
12
+ type MarketContext,
13
+ type OrderbookLevel,
14
+ PerpsErrorCode,
15
+ type Quote,
16
+ type QuoteSide,
17
+ type TradeType,
17
18
  } from '@lifi/perps-types'
19
+ import { PerpsError } from '../errors/PerpsError.js'
18
20
 
19
21
  /**
20
22
  * Calculate position size in asset units from margin.
@@ -319,6 +321,8 @@ interface BookWalk {
319
321
  * notional, the walk stops at the last level and flags `insufficientLiquidity`,
320
322
  * returning the best obtainable fill.
321
323
  *
324
+ * @throws {PerpsError} `ValidationError` when a level's `price` or `size`
325
+ * does not parse to a finite number.
322
326
  * @public
323
327
  */
324
328
  export function walkOrderbook(
@@ -333,7 +337,14 @@ export function walkOrderbook(
333
337
  break
334
338
  }
335
339
  const price = Number.parseFloat(level.price)
336
- const levelNotional = Number.parseFloat(level.size) * price
340
+ const size = Number.parseFloat(level.size)
341
+ if (!Number.isFinite(price) || !Number.isFinite(size)) {
342
+ throw new PerpsError(
343
+ PerpsErrorCode.ValidationError,
344
+ `Malformed orderbook level: price='${level.price}', size='${level.size}'`
345
+ )
346
+ }
347
+ const levelNotional = size * price
337
348
  const take = Math.min(remaining, levelNotional)
338
349
  filledNotional += take
339
350
  baseSize += take / price
@@ -370,6 +381,8 @@ interface BuildQuoteInput {
370
381
  * versus mark, applies the base taker fee on the filled notional, and carries
371
382
  * the market's `funding` (`null` for spot, which has none).
372
383
  *
384
+ * @throws {PerpsError} `ValidationError` when a book level does not parse to
385
+ * a finite number — see {@link walkOrderbook}.
373
386
  * @public
374
387
  */
375
388
  export function buildQuote(input: BuildQuoteInput): Quote {
@@ -1,7 +1,23 @@
1
1
  /**
2
- * Resolve after `ms` milliseconds.
2
+ * Resolve after `ms` milliseconds, or reject with the signal's abort reason if
3
+ * `signal` aborts first. Without a `signal` it always resolves.
3
4
  *
4
5
  * @internal
5
6
  */
6
- export const sleep = (ms: number): Promise<void> =>
7
- new Promise((resolve) => setTimeout(resolve, ms))
7
+ export const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
8
+ new Promise((resolve, reject) => {
9
+ if (signal?.aborted) {
10
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'))
11
+ return
12
+ }
13
+ let timer: ReturnType<typeof setTimeout>
14
+ const onAbort = () => {
15
+ clearTimeout(timer)
16
+ reject(signal?.reason ?? new DOMException('Aborted', 'AbortError'))
17
+ }
18
+ timer = setTimeout(() => {
19
+ signal?.removeEventListener('abort', onAbort)
20
+ resolve()
21
+ }, ms)
22
+ signal?.addEventListener('abort', onAbort, { once: true })
23
+ })
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export const name = '@lifi/perps-sdk'
2
- export const version = '1.5.4'
2
+ export const version = '1.5.6'
@@ -238,16 +238,21 @@ export class ReconnectingWebSocket {
238
238
 
239
239
  /**
240
240
  * Send `data`, buffering it (bounded) for replay when the socket is not yet
241
- * open.
241
+ * open. Throws once {@link ReconnectingWebSocket.close} has been called —
242
+ * there is no socket left to buffer against.
242
243
  *
243
244
  * @public
244
245
  */
245
246
  send(data: string) {
247
+ if (this.closed) {
248
+ throw new Error(CLOSED_ERROR)
249
+ }
246
250
  this.socket.send(data)
247
251
  }
248
252
 
249
253
  /**
250
- * Permanently close the socket and suppress further reconnection.
254
+ * Permanently close the socket, suppress further reconnection, and set
255
+ * status to terminal `disconnected`.
251
256
  *
252
257
  * @public
253
258
  */
@@ -256,6 +261,7 @@ export class ReconnectingWebSocket {
256
261
  this.stopPing()
257
262
  this.clearWatchdog()
258
263
  this.socket.close()
264
+ this.setStatus('disconnected')
259
265
  for (const { reject } of this.readyResolvers) {
260
266
  reject(new Error(CLOSED_ERROR))
261
267
  }
@@ -300,8 +306,9 @@ export class ReconnectingWebSocket {
300
306
 
301
307
  /**
302
308
  * Current connection health. `reconnecting` until the first open;
303
- * `disconnected` once auto-reconnect is abandoned (terminal until
304
- * {@link ReconnectingWebSocket.reconnect} is called).
309
+ * `disconnected` once auto-reconnect is abandoned or {@link
310
+ * ReconnectingWebSocket.close} is called (terminal until {@link
311
+ * ReconnectingWebSocket.reconnect} is called).
305
312
  *
306
313
  * @public
307
314
  */
@@ -94,7 +94,12 @@ export abstract class WsProviderBase<TSub = unknown> implements WsProvider {
94
94
  onStatus(this.rws.getStatus())
95
95
  }
96
96
 
97
+ let released = false
97
98
  return () => {
99
+ if (released) {
100
+ return
101
+ }
102
+ released = true
98
103
  if (onStatus) {
99
104
  const remaining = (this.statusListeners.get(onStatus) ?? 0) - 1
100
105
  if (remaining > 0) {
@@ -188,7 +193,15 @@ export abstract class WsProviderBase<TSub = unknown> implements WsProvider {
188
193
  }
189
194
 
190
195
  private async replaySubs(): Promise<void> {
191
- for (const [key, state] of this.wireSubs) {
196
+ // Snapshot at replay start, and re-check the live registry before each
197
+ // send. An async sendSubscribe (e.g. Lighter's auth-token fetch) suspends
198
+ // the loop; status is already 'connected' while open listeners run, so a
199
+ // subscribe landing mid-replay is sent inline by registerSub — it must not
200
+ // be revisited here, and an entry unregistered mid-replay must be skipped.
201
+ for (const [key, state] of [...this.wireSubs]) {
202
+ if (this.wireSubs.get(key) !== state) {
203
+ continue
204
+ }
192
205
  // Isolate each resubscribe: a send can reject (e.g. an auth-token fetch
193
206
  // after reconnect), and one sub's failure must not abort the rest of
194
207
  // the loop or escape as an unhandled rejection.
@@ -28,9 +28,9 @@ export type SubscriptionListener = (event: SubscriptionEvent) => void
28
28
  * - `connected` — socket open, live data flowing.
29
29
  * - `reconnecting` — socket dropped; auto-reconnect with backoff in progress.
30
30
  * Data may be stale until it returns to `connected`.
31
- * - `disconnected` — reconnection abandoned after the retry cap. The
32
- * connection is permanently dead and will not recover on its own; the
33
- * subscription's data is stale. Terminal.
31
+ * - `disconnected` — reconnection abandoned after the retry cap, or the
32
+ * connection was explicitly closed. The connection is permanently dead and
33
+ * will not recover on its own; the subscription's data is stale. Terminal.
34
34
  *
35
35
  * @public
36
36
  */