@blackcube/xgate-sdk 0.25.0 → 0.25.1

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.d.cts CHANGED
@@ -282,6 +282,8 @@ type Unsubscribe = () => void;
282
282
  */
283
283
  declare class WsCandlesService {
284
284
  private readonly logger;
285
+ /** Un client temps réel par venue — voir {@link wireOf}. La clé EST la venue. */
286
+ private readonly wires;
285
287
  /**
286
288
  * Souscrit aux bougies d'un marché chez une venue. Rend la fonction de désabonnement.
287
289
  *
@@ -308,6 +310,23 @@ declare class WsCandlesService {
308
310
  symbol: string;
309
311
  interval: Timeframe;
310
312
  }, handler: (candle: ICandle) => void): Unsubscribe;
313
+ /**
314
+ * Le client temps réel d'une venue — **créé une seule fois, donc UNE SOCKET PAR VENUE**.
315
+ *
316
+ * C'est la règle que `CandlesStreamService` énonce déjà et que ce service violait : chaque
317
+ * `subscribe()` appelait `createPublicXex(xex)`, qui construit une façade NEUVE. Or la façade
318
+ * mémoïse son client temps réel (`unifiedWs()`), et c'est lui qui porte la socket : une façade
319
+ * par souscription, c'est une socket par souscription.
320
+ *
321
+ * Mesuré le 2026-08-13 sur Blips : 1 104 souscriptions ont ouvert **1 104 sockets** sur sept
322
+ * venues, quand hyperliquid n'en accepte que 10 par IP. Les descripteurs s'épuisent, les venues
323
+ * refusent, et chaque connexion refusée rejetait une promesse que personne n'attendait — le
324
+ * process entier mourait deux minutes après l'ouverture des flux. Ici : **sept sockets**.
325
+ *
326
+ * Rien à fermer explicitement : le ref-count vit dans les SDK (`refs += 1` / `release()`), donc
327
+ * la socket se ferme d'elle-même au dernier désabonnement de la venue, et se rouvre au suivant.
328
+ */
329
+ private wireOf;
311
330
  /** Voir `CandlesService.quoteOf` : la cotation se déduit du symbole natif, faute d'être publiée. */
312
331
  private quoteOf;
313
332
  }
@@ -340,23 +359,39 @@ declare class CandlesStreamService {
340
359
  private readonly unsubscribes;
341
360
  /** Le plafond de souscriptions documenté par la venue, ou `null` si personne ne l'a publié. */
342
361
  readonly maxSubscriptions: number | null;
362
+ /**
363
+ * Notifié quand le flux meurt — après que les souscriptions ont été coupées.
364
+ *
365
+ * **Ce crochet a remplacé un `throw`.** Lever depuis un rappel de socket ne remonte à aucun
366
+ * appelant : l'exception sort en `unhandledRejection` et **tue le process entier**, ce qui est
367
+ * arrivé le 2026-08-13 sur Blips par un autre chemin. « Impossible à ignorer » et « fatal pour
368
+ * tout le monde » ne sont pas la même chose : la première est une garantie, la seconde une panne.
369
+ *
370
+ * Le contrat reste franc — le flux est coupé, il ne repartira pas tout seul, et le silence est
371
+ * journalisé en `.error` de toute façon. Mais c'est à l'appelant de décider ce qu'il en fait :
372
+ * rouvrir, basculer de venue, ou s'arrêter.
373
+ */
374
+ onFailure: ((error: Error) => void) | null;
343
375
  constructor(xex: XgateEx,
344
376
  /** Le marché écouté. Le comptant n'est servi que par binance et bybit. */
345
377
  kind?: MarketKind);
346
378
  /**
347
379
  * Ouvre le flux sur une LISTE de marchés, sur une seule socket.
348
380
  *
349
- * **UNE ERREUR DE FLUX COUPE TOUT, PUIS LÈVE.** Elle n'arrive pas à la souscription : mesuré le
381
+ * **UNE ERREUR DE FLUX COUPE TOUT, PUIS NOTIFIE.** Elle n'arrive pas à la souscription : mesuré le
350
382
  * 2026-08-07, la venue accepte un symbole inconnu sans broncher, puis ferme la connexion en le
351
383
  * découvrant. Le SDK reconnecte, elle referme — cinq cycles en douze secondes, et sur une socket
352
384
  * partagée **tous** les marchés valides cessent de recevoir, en silence.
353
385
  *
354
386
  * On refuse cette boucle : à la première erreur, les souscriptions sont coupées, l'incident est
355
- * journalisé en `error`, et l'exception part. Un flux à moitié mort qui se tait coûte plus cher
356
- * qu'un arrêt franc — on ne découvre le premier qu'en constatant l'absence de données.
357
- *
358
- * ⚠️ L'exception naît dans un rappel de socket : elle ne remonte pas à l'appelant de `start()`,
359
- * elle sort en erreur non capturée. C'est délibéré — elle doit être impossible à ignorer.
387
+ * journalisé en `error`, et {@link onFailure} est notifié. Un flux à moitié mort qui se tait
388
+ * coûte plus cher qu'un arrêt franc — on ne découvre le premier qu'en constatant l'absence de
389
+ * données.
390
+ *
391
+ * ⚠️ **On ne lève pas depuis le rappel de socket.** Une exception née là ne remonte à aucun
392
+ * appelant de `start()` : elle sort en `unhandledRejection` et emporte le process entier — pas
393
+ * seulement le flux fautif, mais les crons et tout ce qui tournait à côté. C'est {@link onFailure}
394
+ * qui porte l'information, et l'appelant qui décide.
360
395
  *
361
396
  * **binance et bybit ne remontent pas encore leurs erreurs** : leur client gère la fermeture en
362
397
  * interne sans l'exposer. Sur ces deux venues, la boucle silencieuse reste possible.
package/dist/index.d.ts CHANGED
@@ -282,6 +282,8 @@ type Unsubscribe = () => void;
282
282
  */
283
283
  declare class WsCandlesService {
284
284
  private readonly logger;
285
+ /** Un client temps réel par venue — voir {@link wireOf}. La clé EST la venue. */
286
+ private readonly wires;
285
287
  /**
286
288
  * Souscrit aux bougies d'un marché chez une venue. Rend la fonction de désabonnement.
287
289
  *
@@ -308,6 +310,23 @@ declare class WsCandlesService {
308
310
  symbol: string;
309
311
  interval: Timeframe;
310
312
  }, handler: (candle: ICandle) => void): Unsubscribe;
313
+ /**
314
+ * Le client temps réel d'une venue — **créé une seule fois, donc UNE SOCKET PAR VENUE**.
315
+ *
316
+ * C'est la règle que `CandlesStreamService` énonce déjà et que ce service violait : chaque
317
+ * `subscribe()` appelait `createPublicXex(xex)`, qui construit une façade NEUVE. Or la façade
318
+ * mémoïse son client temps réel (`unifiedWs()`), et c'est lui qui porte la socket : une façade
319
+ * par souscription, c'est une socket par souscription.
320
+ *
321
+ * Mesuré le 2026-08-13 sur Blips : 1 104 souscriptions ont ouvert **1 104 sockets** sur sept
322
+ * venues, quand hyperliquid n'en accepte que 10 par IP. Les descripteurs s'épuisent, les venues
323
+ * refusent, et chaque connexion refusée rejetait une promesse que personne n'attendait — le
324
+ * process entier mourait deux minutes après l'ouverture des flux. Ici : **sept sockets**.
325
+ *
326
+ * Rien à fermer explicitement : le ref-count vit dans les SDK (`refs += 1` / `release()`), donc
327
+ * la socket se ferme d'elle-même au dernier désabonnement de la venue, et se rouvre au suivant.
328
+ */
329
+ private wireOf;
311
330
  /** Voir `CandlesService.quoteOf` : la cotation se déduit du symbole natif, faute d'être publiée. */
312
331
  private quoteOf;
313
332
  }
@@ -340,23 +359,39 @@ declare class CandlesStreamService {
340
359
  private readonly unsubscribes;
341
360
  /** Le plafond de souscriptions documenté par la venue, ou `null` si personne ne l'a publié. */
342
361
  readonly maxSubscriptions: number | null;
362
+ /**
363
+ * Notifié quand le flux meurt — après que les souscriptions ont été coupées.
364
+ *
365
+ * **Ce crochet a remplacé un `throw`.** Lever depuis un rappel de socket ne remonte à aucun
366
+ * appelant : l'exception sort en `unhandledRejection` et **tue le process entier**, ce qui est
367
+ * arrivé le 2026-08-13 sur Blips par un autre chemin. « Impossible à ignorer » et « fatal pour
368
+ * tout le monde » ne sont pas la même chose : la première est une garantie, la seconde une panne.
369
+ *
370
+ * Le contrat reste franc — le flux est coupé, il ne repartira pas tout seul, et le silence est
371
+ * journalisé en `.error` de toute façon. Mais c'est à l'appelant de décider ce qu'il en fait :
372
+ * rouvrir, basculer de venue, ou s'arrêter.
373
+ */
374
+ onFailure: ((error: Error) => void) | null;
343
375
  constructor(xex: XgateEx,
344
376
  /** Le marché écouté. Le comptant n'est servi que par binance et bybit. */
345
377
  kind?: MarketKind);
346
378
  /**
347
379
  * Ouvre le flux sur une LISTE de marchés, sur une seule socket.
348
380
  *
349
- * **UNE ERREUR DE FLUX COUPE TOUT, PUIS LÈVE.** Elle n'arrive pas à la souscription : mesuré le
381
+ * **UNE ERREUR DE FLUX COUPE TOUT, PUIS NOTIFIE.** Elle n'arrive pas à la souscription : mesuré le
350
382
  * 2026-08-07, la venue accepte un symbole inconnu sans broncher, puis ferme la connexion en le
351
383
  * découvrant. Le SDK reconnecte, elle referme — cinq cycles en douze secondes, et sur une socket
352
384
  * partagée **tous** les marchés valides cessent de recevoir, en silence.
353
385
  *
354
386
  * On refuse cette boucle : à la première erreur, les souscriptions sont coupées, l'incident est
355
- * journalisé en `error`, et l'exception part. Un flux à moitié mort qui se tait coûte plus cher
356
- * qu'un arrêt franc — on ne découvre le premier qu'en constatant l'absence de données.
357
- *
358
- * ⚠️ L'exception naît dans un rappel de socket : elle ne remonte pas à l'appelant de `start()`,
359
- * elle sort en erreur non capturée. C'est délibéré — elle doit être impossible à ignorer.
387
+ * journalisé en `error`, et {@link onFailure} est notifié. Un flux à moitié mort qui se tait
388
+ * coûte plus cher qu'un arrêt franc — on ne découvre le premier qu'en constatant l'absence de
389
+ * données.
390
+ *
391
+ * ⚠️ **On ne lève pas depuis le rappel de socket.** Une exception née là ne remonte à aucun
392
+ * appelant de `start()` : elle sort en `unhandledRejection` et emporte le process entier — pas
393
+ * seulement le flux fautif, mais les crons et tout ce qui tournait à côté. C'est {@link onFailure}
394
+ * qui porte l'information, et l'appelant qui décide.
360
395
  *
361
396
  * **binance et bybit ne remontent pas encore leurs erreurs** : leur client gère la fermeture en
362
397
  * interne sans l'exposer. Sur ces deux venues, la boucle silencieuse reste possible.
package/dist/index.js CHANGED
@@ -95,6 +95,24 @@ function toCandle(wire, xex, interval, quote) {
95
95
  };
96
96
  }
97
97
 
98
+ // src/helpers/socket-error.ts
99
+ function lisible(error) {
100
+ if (error instanceof Error) {
101
+ return error.message;
102
+ }
103
+ const event = error;
104
+ if (event !== null && typeof event === "object") {
105
+ const message = event.message ?? event.error?.message;
106
+ if (typeof message === "string" && message !== "") {
107
+ return message;
108
+ }
109
+ if (typeof event.type === "string" && event.type !== "") {
110
+ return `\xE9v\xE9nement \xAB ${event.type} \xBB`;
111
+ }
112
+ }
113
+ return String(error);
114
+ }
115
+
98
116
  // src/helpers/ws-limits.ts
99
117
  var WS_SUBSCRIPTION_LIMITS = {
100
118
  aster: 200,
@@ -264,20 +282,36 @@ var CandlesStreamService = class {
264
282
  unsubscribes = [];
265
283
  /** Le plafond de souscriptions documenté par la venue, ou `null` si personne ne l'a publié. */
266
284
  maxSubscriptions;
285
+ /**
286
+ * Notifié quand le flux meurt — après que les souscriptions ont été coupées.
287
+ *
288
+ * **Ce crochet a remplacé un `throw`.** Lever depuis un rappel de socket ne remonte à aucun
289
+ * appelant : l'exception sort en `unhandledRejection` et **tue le process entier**, ce qui est
290
+ * arrivé le 2026-08-13 sur Blips par un autre chemin. « Impossible à ignorer » et « fatal pour
291
+ * tout le monde » ne sont pas la même chose : la première est une garantie, la seconde une panne.
292
+ *
293
+ * Le contrat reste franc — le flux est coupé, il ne repartira pas tout seul, et le silence est
294
+ * journalisé en `.error` de toute façon. Mais c'est à l'appelant de décider ce qu'il en fait :
295
+ * rouvrir, basculer de venue, ou s'arrêter.
296
+ */
297
+ onFailure = null;
267
298
  /**
268
299
  * Ouvre le flux sur une LISTE de marchés, sur une seule socket.
269
300
  *
270
- * **UNE ERREUR DE FLUX COUPE TOUT, PUIS LÈVE.** Elle n'arrive pas à la souscription : mesuré le
301
+ * **UNE ERREUR DE FLUX COUPE TOUT, PUIS NOTIFIE.** Elle n'arrive pas à la souscription : mesuré le
271
302
  * 2026-08-07, la venue accepte un symbole inconnu sans broncher, puis ferme la connexion en le
272
303
  * découvrant. Le SDK reconnecte, elle referme — cinq cycles en douze secondes, et sur une socket
273
304
  * partagée **tous** les marchés valides cessent de recevoir, en silence.
274
305
  *
275
306
  * On refuse cette boucle : à la première erreur, les souscriptions sont coupées, l'incident est
276
- * journalisé en `error`, et l'exception part. Un flux à moitié mort qui se tait coûte plus cher
277
- * qu'un arrêt franc — on ne découvre le premier qu'en constatant l'absence de données.
307
+ * journalisé en `error`, et {@link onFailure} est notifié. Un flux à moitié mort qui se tait
308
+ * coûte plus cher qu'un arrêt franc — on ne découvre le premier qu'en constatant l'absence de
309
+ * données.
278
310
  *
279
- * ⚠️ L'exception naît dans un rappel de socket : elle ne remonte pas à l'appelant de `start()`,
280
- * elle sort en erreur non capturée. C'est délibéré — elle doit être impossible à ignorer.
311
+ * ⚠️ **On ne lève pas depuis le rappel de socket.** Une exception née là ne remonte à aucun
312
+ * appelant de `start()` : elle sort en `unhandledRejection` et emporte le process entier — pas
313
+ * seulement le flux fautif, mais les crons et tout ce qui tournait à côté. C'est {@link onFailure}
314
+ * qui porte l'information, et l'appelant qui décide.
281
315
  *
282
316
  * **binance et bybit ne remontent pas encore leurs erreurs** : leur client gère la fermeture en
283
317
  * interne sans l'exposer. Sur ces deux venues, la boucle silencieuse reste possible.
@@ -289,7 +323,7 @@ var CandlesStreamService = class {
289
323
  const raison = lisible(error);
290
324
  this.logger.error(`${this.xex} : erreur de flux \u2014 ${raison}. Souscriptions coup\xE9es.`);
291
325
  this.stop();
292
- throw new Error(`stream(${this.xex}) : flux interrompu \u2014 ${raison}`);
326
+ this.onFailure?.(new Error(`stream(${this.xex}) : flux interrompu \u2014 ${raison}`));
293
327
  };
294
328
  }
295
329
  for (const symbolXex of symbolsXex) {
@@ -372,22 +406,6 @@ var CandlesStreamRegistry = class {
372
406
  CandlesStreamRegistry = __decorateClass([
373
407
  Injectable()
374
408
  ], CandlesStreamRegistry);
375
- function lisible(error) {
376
- if (error instanceof Error) {
377
- return error.message;
378
- }
379
- const event = error;
380
- if (event !== null && typeof event === "object") {
381
- const message = event.message ?? event.error?.message;
382
- if (typeof message === "string" && message !== "") {
383
- return message;
384
- }
385
- if (typeof event.type === "string" && event.type !== "") {
386
- return `\xE9v\xE9nement \xAB ${event.type} \xBB`;
387
- }
388
- }
389
- return String(error);
390
- }
391
409
 
392
410
  // src/enums/timeframe.enums.ts
393
411
  var Timeframe = /* @__PURE__ */ ((Timeframe2) => {
@@ -1819,6 +1837,8 @@ WalletService = __decorateClass([
1819
1837
  ], WalletService);
1820
1838
  var WsCandlesService = class {
1821
1839
  logger = new Logger(WsCandlesService.name);
1840
+ /** Un client temps réel par venue — voir {@link wireOf}. La clé EST la venue. */
1841
+ wires = /* @__PURE__ */ new Map();
1822
1842
  /**
1823
1843
  * Souscrit aux bougies d'un marché chez une venue. Rend la fonction de désabonnement.
1824
1844
  *
@@ -1834,9 +1854,12 @@ var WsCandlesService = class {
1834
1854
  subscribe(xex, query, handler) {
1835
1855
  const quote = this.quoteOf(query.symbol);
1836
1856
  this.logger.log(`souscription bougies ${query.symbol} ${query.interval} sur ${xex}`);
1837
- return createPublicXex(xex).ws().subscribeCandles({ name: query.symbol, interval: query.interval }, (wire) => {
1838
- handler(toCandle(wire, xex, query.interval, quote));
1839
- });
1857
+ return this.wireOf(xex).subscribeCandles(
1858
+ { name: query.symbol, interval: query.interval },
1859
+ (wire) => {
1860
+ handler(toCandle(wire, xex, query.interval, quote));
1861
+ }
1862
+ );
1840
1863
  }
1841
1864
  /**
1842
1865
  * Souscrit au MÊME marché chez plusieurs venues, avec un seul handler.
@@ -1863,6 +1886,35 @@ var WsCandlesService = class {
1863
1886
  }
1864
1887
  };
1865
1888
  }
1889
+ /**
1890
+ * Le client temps réel d'une venue — **créé une seule fois, donc UNE SOCKET PAR VENUE**.
1891
+ *
1892
+ * C'est la règle que `CandlesStreamService` énonce déjà et que ce service violait : chaque
1893
+ * `subscribe()` appelait `createPublicXex(xex)`, qui construit une façade NEUVE. Or la façade
1894
+ * mémoïse son client temps réel (`unifiedWs()`), et c'est lui qui porte la socket : une façade
1895
+ * par souscription, c'est une socket par souscription.
1896
+ *
1897
+ * Mesuré le 2026-08-13 sur Blips : 1 104 souscriptions ont ouvert **1 104 sockets** sur sept
1898
+ * venues, quand hyperliquid n'en accepte que 10 par IP. Les descripteurs s'épuisent, les venues
1899
+ * refusent, et chaque connexion refusée rejetait une promesse que personne n'attendait — le
1900
+ * process entier mourait deux minutes après l'ouverture des flux. Ici : **sept sockets**.
1901
+ *
1902
+ * Rien à fermer explicitement : le ref-count vit dans les SDK (`refs += 1` / `release()`), donc
1903
+ * la socket se ferme d'elle-même au dernier désabonnement de la venue, et se rouvre au suivant.
1904
+ */
1905
+ wireOf(xex) {
1906
+ let wire = this.wires.get(xex);
1907
+ if (wire === void 0) {
1908
+ wire = createPublicXex(xex).ws();
1909
+ if ("onError" in wire) {
1910
+ wire.onError = (error) => {
1911
+ this.logger.error(`${xex} : erreur de flux \u2014 ${lisible(error)}`);
1912
+ };
1913
+ }
1914
+ this.wires.set(xex, wire);
1915
+ }
1916
+ return wire;
1917
+ }
1866
1918
  /** Voir `CandlesService.quoteOf` : la cotation se déduit du symbole natif, faute d'être publiée. */
1867
1919
  quoteOf(symbolXex) {
1868
1920
  const parts = symbolXex.split("-");