@aurabase/realtime 0.4.0 → 0.4.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/README.md CHANGED
@@ -61,6 +61,21 @@ sur réseau mobile ou derrière un proxy, cas où aucun `close` n'est jamais ém
61
61
  alors la socket, notifie via `onError` (`phase: 'liveness'`) et `CHANNEL_ERROR` sur chaque canal,
62
62
  puis relance la reconnexion automatique (backoff exponentiel avec jitter).
63
63
 
64
+ Trois filets couvrent les défaillances du Worker lui-même, car s'il se tait le client ne ping plus
65
+ *et* ne détecte plus rien :
66
+
67
+ | Défaillance | Détection | Réaction |
68
+ | --- | --- | --- |
69
+ | `Worker` indisponible, construction refusée | exception synchrone | fallback `setInterval` |
70
+ | script refusé par la CSP (`worker-src` sans `blob:`) | événement `error` **asynchrone** — le constructeur ne lève pas | fallback `setInterval` |
71
+ | Worker construit mais inerte | absence d'accusé `ready` sous 3 s | fallback `setInterval` |
72
+ | Worker tué silencieusement en cours de route | filet de liveness du thread principal (90 s sans message serveur) | reconnexion |
73
+
74
+ Le refresh proactif du token suit le cycle de vie de la connexion : maintenu pendant une
75
+ reconnexion (le prochain handshake a besoin d'un token valide), **annulé** dès que l'état devient
76
+ `disconnected` sans reconnexion prévue — un service arrêté ne doit pas continuer à faire tourner
77
+ les jetons de la session en arrière-plan.
78
+
64
79
  ### Erreurs d'authentification à la connexion
65
80
 
66
81
  Un handshake refusé par le serveur (token JWT absent/expiré/invalide → HTTP 401) est signalé via
package/dist/index.cjs CHANGED
@@ -300,6 +300,29 @@ var AuraChannel = class {
300
300
  this._sendFn(JSON.stringify(msg));
301
301
  this._armSubscribeTimeout();
302
302
  }
303
+ /**
304
+ * Notifie le callback de statut d'abonnement, sauf si le canal est fermé.
305
+ *
306
+ * Un canal sorti par `unsubscribe()` est **soldé** : son cycle de vie s'est
307
+ * terminé sur `CLOSED`. Rien ne doit plus le faire parler — ni une erreur
308
+ * serveur globale rediffusée à tous les canaux, ni une confirmation `subscribed`
309
+ * encore en vol au moment du désabonnement. Sans cette garde, l'appelant voyait
310
+ * un `CHANNEL_ERROR` ou un `SUBSCRIBED` APRÈS le `CLOSED` de son propre canal.
311
+ *
312
+ * Parité `@supabase/realtime-js` : un canal quitté est retiré de
313
+ * `socket.channels` (`RealtimeClient#_remove`) et échappe donc par construction
314
+ * à `Socket#triggerChanError` et au routage des réponses. Le SDK Aurabase garde
315
+ * l'instance dans sa Map (`unsubscribe()` ne la retire pas, seul
316
+ * `removeChannel()` le fait), la garde est donc portée par le canal lui-même.
317
+ *
318
+ * `CLOSED` est la seule exception : il est émis précisément au moment où
319
+ * `_subscribed` bascule à `false` — d'où son émission directe dans
320
+ * `unsubscribe()`.
321
+ */
322
+ _emitStatus(status, err) {
323
+ if (!this._subscribed) return;
324
+ this._subscribeCallback?.(status, err);
325
+ }
303
326
  /** (Re)arme le compte à rebours d'attente de la confirmation `subscribed`. */
304
327
  _armSubscribeTimeout() {
305
328
  this._clearSubscribeTimeout();
@@ -326,8 +349,7 @@ var AuraChannel = class {
326
349
  */
327
350
  _signalError(err) {
328
351
  this._clearSubscribeTimeout();
329
- if (!this._subscribed) return;
330
- this._subscribeCallback?.("CHANNEL_ERROR", err);
352
+ this._emitStatus("CHANNEL_ERROR", err);
331
353
  }
332
354
  /** Unsubscribe from the channel. */
333
355
  unsubscribe() {
@@ -423,7 +445,7 @@ var AuraChannel = class {
423
445
  const subMsg = msg;
424
446
  if (subMsg.subscription_id) this._subscriptionId = subMsg.subscription_id;
425
447
  this._clearSubscribeTimeout();
426
- this._subscribeCallback?.("SUBSCRIBED");
448
+ this._emitStatus("SUBSCRIBED");
427
449
  break;
428
450
  }
429
451
  case "event": {
@@ -442,6 +464,7 @@ var AuraChannel = class {
442
464
  break;
443
465
  }
444
466
  case "presence_state": {
467
+ if (!this._subscribed) break;
445
468
  const presMsg = msg;
446
469
  if (presMsg.presences) {
447
470
  this._presenceState = {};
@@ -461,7 +484,7 @@ var AuraChannel = class {
461
484
  case "error": {
462
485
  const errMsg = msg;
463
486
  this._clearSubscribeTimeout();
464
- this._subscribeCallback?.("CHANNEL_ERROR", new Error(errMsg.message ?? "Unknown error"));
487
+ this._emitStatus("CHANNEL_ERROR", new Error(errMsg.message ?? "Unknown error"));
465
488
  break;
466
489
  }
467
490
  case "token_refreshed":
@@ -601,44 +624,87 @@ var AuraChannel = class {
601
624
  };
602
625
 
603
626
  // src/HeartbeatManager.ts
604
- var HeartbeatManager = class {
627
+ var HeartbeatManager = class _HeartbeatManager {
605
628
  _worker = null;
606
629
  _workerUrl = null;
607
630
  _interval = null;
608
631
  _sendFn = null;
632
+ /** Chien de garde de l'accusé `ready` du Worker (mode de défaillance 3). */
633
+ _watchdog = null;
634
+ /**
635
+ * Numéro du cycle start/stop courant. Un événement asynchrone (erreur de
636
+ * Worker, chien de garde) portant un numéro périmé appartient à un cycle déjà
637
+ * arrêté ou remplacé : il doit être ignoré, jamais ressusciter un heartbeat.
638
+ */
639
+ _generation = 0;
640
+ /**
641
+ * Délai accordé au Worker pour accuser réception du `start`.
642
+ *
643
+ * Volontairement **indépendant de l'intervalle de heartbeat** : c'est un temps
644
+ * de démarrage de thread, pas un multiple de la cadence. 3 s est très large
645
+ * pour un Worker sain (quelques millisecondes en pratique) tout en restant
646
+ * loin sous le `heartbeat_timeout` de 60 s du serveur.
647
+ */
648
+ static WORKER_READY_TIMEOUT_MS = 3e3;
609
649
  start(sendPing, intervalMs = 25e3) {
610
650
  this.stop();
651
+ const generation = ++this._generation;
611
652
  this._sendFn = sendPing;
612
653
  if (typeof Worker !== "undefined" && typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
613
654
  try {
614
- const code = `let i=null;onmessage=(e)=>{if(e.data.type==='start'){if(i)clearInterval(i);i=setInterval(()=>postMessage('ping'),e.data.interval||25000);}else if(e.data.type==='stop'){if(i){clearInterval(i);i=null;}}};`;
655
+ const code = `let i=null;onmessage=(e)=>{if(e.data.type==='start'){if(i)clearInterval(i);postMessage('ready');i=setInterval(()=>postMessage('ping'),e.data.interval||25000);}else if(e.data.type==='stop'){if(i){clearInterval(i);i=null;}}};`;
615
656
  const blob = new Blob([code], { type: "application/javascript" });
616
657
  this._workerUrl = URL.createObjectURL(blob);
617
- this._worker = new Worker(this._workerUrl);
618
- this._worker.onmessage = () => {
658
+ const worker = new Worker(this._workerUrl);
659
+ this._worker = worker;
660
+ worker.onmessage = (evt) => {
661
+ if (generation !== this._generation) return;
662
+ this._clearWatchdog();
663
+ if (evt?.data === "ready") return;
619
664
  this._sendFn?.();
620
665
  };
621
- this._worker.postMessage({ type: "start", interval: intervalMs });
666
+ worker.onerror = () => {
667
+ this._fallbackFromWorker(generation, intervalMs);
668
+ };
669
+ worker.postMessage({ type: "start", interval: intervalMs });
670
+ this._watchdog = setTimeout(
671
+ () => this._fallbackFromWorker(generation, intervalMs),
672
+ _HeartbeatManager.WORKER_READY_TIMEOUT_MS
673
+ );
622
674
  return;
623
675
  } catch {
624
- this._worker = null;
625
- if (this._workerUrl) {
626
- try {
627
- URL.revokeObjectURL(this._workerUrl);
628
- } catch {
629
- }
630
- this._workerUrl = null;
631
- }
676
+ this._disposeWorker();
632
677
  }
633
678
  }
679
+ this._startFallback(intervalMs);
680
+ }
681
+ /**
682
+ * Abandonne le Worker et reprend le battement avec un `setInterval`.
683
+ * Idempotent et insensible aux cycles périmés.
684
+ */
685
+ _fallbackFromWorker(generation, intervalMs) {
686
+ if (generation !== this._generation) return;
687
+ if (this._interval) return;
688
+ if (!this._sendFn) return;
689
+ this._clearWatchdog();
690
+ this._disposeWorker();
691
+ this._startFallback(intervalMs);
692
+ }
693
+ _startFallback(intervalMs) {
634
694
  this._interval = setInterval(() => {
635
695
  this._sendFn?.();
636
696
  }, intervalMs);
637
697
  }
638
- stop() {
698
+ _clearWatchdog() {
699
+ if (this._watchdog) {
700
+ clearTimeout(this._watchdog);
701
+ this._watchdog = null;
702
+ }
703
+ }
704
+ /** Termine le Worker courant et libère l'URL d'objet associée. */
705
+ _disposeWorker() {
639
706
  if (this._worker) {
640
707
  try {
641
- this._worker.postMessage({ type: "stop" });
642
708
  this._worker.terminate();
643
709
  } catch {
644
710
  }
@@ -651,6 +717,17 @@ var HeartbeatManager = class {
651
717
  }
652
718
  this._workerUrl = null;
653
719
  }
720
+ }
721
+ stop() {
722
+ this._generation++;
723
+ this._clearWatchdog();
724
+ if (this._worker) {
725
+ try {
726
+ this._worker.postMessage({ type: "stop" });
727
+ } catch {
728
+ }
729
+ }
730
+ this._disposeWorker();
654
731
  if (this._interval) {
655
732
  clearInterval(this._interval);
656
733
  this._interval = null;
@@ -711,6 +788,8 @@ var RealtimeConnectionError = class _RealtimeConnectionError extends Error {
711
788
  // src/RealtimeService.ts
712
789
  var HEARTBEAT_INTERVAL_MS = 25e3;
713
790
  var MAX_TIMEOUT_MS = 2147483647;
791
+ var LIVENESS_GUARD_INTERVAL_MS = 3e4;
792
+ var LIVENESS_MAX_SILENCE_MS = 9e4;
714
793
  var RealtimeService = class _RealtimeService {
715
794
  client;
716
795
  _ws = null;
@@ -736,6 +815,17 @@ var RealtimeService = class _RealtimeService {
736
815
  * 30 s et répond au ping : la connexion est morte (TCP à demi-fermé).
737
816
  */
738
817
  _awaitingPong = false;
818
+ /** Date du dernier message serveur reçu, pour le filet de liveness. */
819
+ _lastServerMessageAt = 0;
820
+ /**
821
+ * Filet de liveness, indépendant du `HeartbeatManager`.
822
+ *
823
+ * Sans lui, toute la détection de connexion morte reposait sur le tick du
824
+ * heartbeat — lui-même porté par un Worker. Un Worker tué silencieusement (par
825
+ * la pression mémoire d'un mobile, sans événement `error`) supprimait d'un coup
826
+ * le ping ET la détection : le heartbeat était un point unique de défaillance.
827
+ */
828
+ _livenessGuard = null;
739
829
  /** Backoff delays in ms: 1s, 2s, 4s, 8s, 16s, 30s (cap) */
740
830
  static BACKOFF_MS = [1e3, 2e3, 4e3, 8e3, 16e3, 3e4];
741
831
  constructor(client) {
@@ -840,7 +930,9 @@ var RealtimeService = class _RealtimeService {
840
930
  this._reconnectAttempts = 0;
841
931
  this._setState("connected");
842
932
  this._awaitingPong = false;
933
+ this._lastServerMessageAt = Date.now();
843
934
  this._heartbeat.start(() => this._heartbeatTick(ws), HEARTBEAT_INTERVAL_MS);
935
+ this._startLivenessGuard(ws);
844
936
  this._scheduleTokenRefresh();
845
937
  for (const channel of this._channels.values()) {
846
938
  channel._bindSend((data) => this._send(data));
@@ -852,6 +944,7 @@ var RealtimeService = class _RealtimeService {
852
944
  ws.onmessage = (evt) => {
853
945
  if (ws !== this._ws) return;
854
946
  this._awaitingPong = false;
947
+ this._lastServerMessageAt = Date.now();
855
948
  try {
856
949
  const raw = typeof evt.data === "string" ? evt.data : "";
857
950
  const msg = JSON.parse(raw);
@@ -880,12 +973,12 @@ var RealtimeService = class _RealtimeService {
880
973
  );
881
974
  }
882
975
  if (intentional || this._intentionalClose) {
883
- this._setState("disconnected");
976
+ this._goDisconnected();
884
977
  this._intentionalClose = false;
885
978
  } else if (this._autoReconnect) {
886
979
  this._scheduleReconnect();
887
980
  } else {
888
- this._setState("disconnected");
981
+ this._goDisconnected();
889
982
  }
890
983
  };
891
984
  ws.onerror = (evt) => {
@@ -905,6 +998,61 @@ var RealtimeService = class _RealtimeService {
905
998
  this._lastSentToken = null;
906
999
  this._awaitingPong = false;
907
1000
  this._heartbeat.stop();
1001
+ this._stopLivenessGuard();
1002
+ }
1003
+ /**
1004
+ * Arme le filet de liveness pour `ws` : il ne juge que sur la date du dernier
1005
+ * message reçu, donc il survit à la mort complète du heartbeat.
1006
+ *
1007
+ * Il vit sur le thread principal, qu'un navigateur throttle en arrière-plan :
1008
+ * cela ne peut que le RETARDER, jamais provoquer un faux positif — le verdict
1009
+ * porte sur une durée mesurée, pas sur le nombre de ticks écoulés.
1010
+ */
1011
+ _startLivenessGuard(ws) {
1012
+ this._stopLivenessGuard();
1013
+ this._livenessGuard = setInterval(() => {
1014
+ if (ws !== this._ws) return;
1015
+ const silence = Date.now() - this._lastServerMessageAt;
1016
+ if (silence < LIVENESS_MAX_SILENCE_MS) return;
1017
+ this._handleDeadConnection(
1018
+ `Realtime : aucun message du serveur depuis ${Math.round(silence / 1e3)} s alors qu'il en pousse un toutes les 30 s \u2014 connexion consid\xE9r\xE9e morte. Reconnexion.`
1019
+ );
1020
+ }, LIVENESS_GUARD_INTERVAL_MS);
1021
+ }
1022
+ _stopLivenessGuard() {
1023
+ if (this._livenessGuard) {
1024
+ clearInterval(this._livenessGuard);
1025
+ this._livenessGuard = null;
1026
+ }
1027
+ }
1028
+ /** Annule le refresh proactif du token, s'il est armé. */
1029
+ _cancelTokenRefresh() {
1030
+ if (this._tokenRefreshTimer) {
1031
+ clearTimeout(this._tokenRefreshTimer);
1032
+ this._tokenRefreshTimer = null;
1033
+ }
1034
+ }
1035
+ /**
1036
+ * Bascule vers l'état terminal `disconnected` : plus aucune reconnexion n'est
1037
+ * prévue, le service doit donc **cesser toute activité de fond**.
1038
+ *
1039
+ * Le point sensible est le refresh proactif du token. Le laisser courir n'est
1040
+ * pas anodin : `auth.refreshSession()` frappe le backend, qui applique une
1041
+ * ROTATION STRICTE — chaque refresh révoque l'ancien refresh token
1042
+ * (`services/aura-auth/src/services/auth_service.rs`, « Rotation atomique (E2) »),
1043
+ * et rejouer un token révoqué au-delà de la fenêtre de grâce déclenche
1044
+ * `token_reuse_detected` → **révocation globale de toutes les sessions de
1045
+ * l'utilisateur**. Comme `refreshToken()` replanifie systématiquement le timer,
1046
+ * un service pourtant `disconnected` faisait tourner les jetons de la session à
1047
+ * perpétuité, en tâche de fond, sans que personne ne l'ait demandé.
1048
+ *
1049
+ * En revanche le refresh est **conservé pendant une reconnexion** : le prochain
1050
+ * handshake lit `authStore.token`, et un backoff un peu long laisserait sinon
1051
+ * expirer le token — le ré-handshake partirait alors en 401.
1052
+ */
1053
+ _goDisconnected() {
1054
+ this._cancelTokenRefresh();
1055
+ this._setState("disconnected");
908
1056
  }
909
1057
  /**
910
1058
  * Un tick de heartbeat : vérifie la liveness du lien puis émet un ping.
@@ -955,7 +1103,7 @@ var RealtimeService = class _RealtimeService {
955
1103
  this._scheduleReconnect();
956
1104
  } else {
957
1105
  this._intentionalClose = false;
958
- this._setState("disconnected");
1106
+ this._goDisconnected();
959
1107
  }
960
1108
  }
961
1109
  /** Send raw data through the WebSocket. */
@@ -1035,10 +1183,7 @@ var RealtimeService = class _RealtimeService {
1035
1183
  * Prevents token expiration during an active WebSocket session.
1036
1184
  */
1037
1185
  _scheduleTokenRefresh() {
1038
- if (this._tokenRefreshTimer) {
1039
- clearTimeout(this._tokenRefreshTimer);
1040
- this._tokenRefreshTimer = null;
1041
- }
1186
+ this._cancelTokenRefresh();
1042
1187
  const token = this.client.authStore.token;
1043
1188
  if (!token) return;
1044
1189
  const exp = getTokenExpiryMs(token);
@@ -1071,7 +1216,7 @@ var RealtimeService = class _RealtimeService {
1071
1216
  this._reconnectTimer = null;
1072
1217
  }
1073
1218
  if (this._reconnectAttempts >= this._maxReconnectAttempts) {
1074
- this._setState("disconnected");
1219
+ this._goDisconnected();
1075
1220
  return;
1076
1221
  }
1077
1222
  this._reconnectAttempts++;
@@ -1094,10 +1239,8 @@ var RealtimeService = class _RealtimeService {
1094
1239
  disconnect() {
1095
1240
  this._intentionalClose = true;
1096
1241
  this._heartbeat.stop();
1097
- if (this._tokenRefreshTimer) {
1098
- clearTimeout(this._tokenRefreshTimer);
1099
- this._tokenRefreshTimer = null;
1100
- }
1242
+ this._stopLivenessGuard();
1243
+ this._cancelTokenRefresh();
1101
1244
  if (this._reconnectTimer) {
1102
1245
  clearTimeout(this._reconnectTimer);
1103
1246
  this._reconnectTimer = null;
package/dist/index.d.cts CHANGED
@@ -95,6 +95,26 @@ declare class AuraChannel {
95
95
  _bindSend(sendFn: SendFn): void;
96
96
  /** Send the subscribe message to the server. */
97
97
  _doSubscribe(): void;
98
+ /**
99
+ * Notifie le callback de statut d'abonnement, sauf si le canal est fermé.
100
+ *
101
+ * Un canal sorti par `unsubscribe()` est **soldé** : son cycle de vie s'est
102
+ * terminé sur `CLOSED`. Rien ne doit plus le faire parler — ni une erreur
103
+ * serveur globale rediffusée à tous les canaux, ni une confirmation `subscribed`
104
+ * encore en vol au moment du désabonnement. Sans cette garde, l'appelant voyait
105
+ * un `CHANNEL_ERROR` ou un `SUBSCRIBED` APRÈS le `CLOSED` de son propre canal.
106
+ *
107
+ * Parité `@supabase/realtime-js` : un canal quitté est retiré de
108
+ * `socket.channels` (`RealtimeClient#_remove`) et échappe donc par construction
109
+ * à `Socket#triggerChanError` et au routage des réponses. Le SDK Aurabase garde
110
+ * l'instance dans sa Map (`unsubscribe()` ne la retire pas, seul
111
+ * `removeChannel()` le fait), la garde est donc portée par le canal lui-même.
112
+ *
113
+ * `CLOSED` est la seule exception : il est émis précisément au moment où
114
+ * `_subscribed` bascule à `false` — d'où son émission directe dans
115
+ * `unsubscribe()`.
116
+ */
117
+ private _emitStatus;
98
118
  /** (Re)arme le compte à rebours d'attente de la confirmation `subscribed`. */
99
119
  private _armSubscribeTimeout;
100
120
  private _clearSubscribeTimeout;
@@ -251,6 +271,17 @@ declare class RealtimeService {
251
271
  * 30 s et répond au ping : la connexion est morte (TCP à demi-fermé).
252
272
  */
253
273
  private _awaitingPong;
274
+ /** Date du dernier message serveur reçu, pour le filet de liveness. */
275
+ private _lastServerMessageAt;
276
+ /**
277
+ * Filet de liveness, indépendant du `HeartbeatManager`.
278
+ *
279
+ * Sans lui, toute la détection de connexion morte reposait sur le tick du
280
+ * heartbeat — lui-même porté par un Worker. Un Worker tué silencieusement (par
281
+ * la pression mémoire d'un mobile, sans événement `error`) supprimait d'un coup
282
+ * le ping ET la détection : le heartbeat était un point unique de défaillance.
283
+ */
284
+ private _livenessGuard;
254
285
  /** Backoff delays in ms: 1s, 2s, 4s, 8s, 16s, 30s (cap) */
255
286
  private static readonly BACKOFF_MS;
256
287
  constructor(client: AuraClientLike);
@@ -298,6 +329,37 @@ declare class RealtimeService {
298
329
  * compter sur un `onclose` : une socket à demi-fermée n'en émet parfois jamais).
299
330
  */
300
331
  private _detachSocket;
332
+ /**
333
+ * Arme le filet de liveness pour `ws` : il ne juge que sur la date du dernier
334
+ * message reçu, donc il survit à la mort complète du heartbeat.
335
+ *
336
+ * Il vit sur le thread principal, qu'un navigateur throttle en arrière-plan :
337
+ * cela ne peut que le RETARDER, jamais provoquer un faux positif — le verdict
338
+ * porte sur une durée mesurée, pas sur le nombre de ticks écoulés.
339
+ */
340
+ private _startLivenessGuard;
341
+ private _stopLivenessGuard;
342
+ /** Annule le refresh proactif du token, s'il est armé. */
343
+ private _cancelTokenRefresh;
344
+ /**
345
+ * Bascule vers l'état terminal `disconnected` : plus aucune reconnexion n'est
346
+ * prévue, le service doit donc **cesser toute activité de fond**.
347
+ *
348
+ * Le point sensible est le refresh proactif du token. Le laisser courir n'est
349
+ * pas anodin : `auth.refreshSession()` frappe le backend, qui applique une
350
+ * ROTATION STRICTE — chaque refresh révoque l'ancien refresh token
351
+ * (`services/aura-auth/src/services/auth_service.rs`, « Rotation atomique (E2) »),
352
+ * et rejouer un token révoqué au-delà de la fenêtre de grâce déclenche
353
+ * `token_reuse_detected` → **révocation globale de toutes les sessions de
354
+ * l'utilisateur**. Comme `refreshToken()` replanifie systématiquement le timer,
355
+ * un service pourtant `disconnected` faisait tourner les jetons de la session à
356
+ * perpétuité, en tâche de fond, sans que personne ne l'ait demandé.
357
+ *
358
+ * En revanche le refresh est **conservé pendant une reconnexion** : le prochain
359
+ * handshake lit `authStore.token`, et un backoff un peu long laisserait sinon
360
+ * expirer le token — le ré-handshake partirait alors en 401.
361
+ */
362
+ private _goDisconnected;
301
363
  /**
302
364
  * Un tick de heartbeat : vérifie la liveness du lien puis émet un ping.
303
365
  *
@@ -404,14 +466,67 @@ declare class RealtimeService {
404
466
 
405
467
  /**
406
468
  * HeartbeatManager — pings périodiques pour maintenir la connexion WS.
407
- * Utilise un Worker dédié (anti-throttle des onglets en arrière-plan) ; fallback setInterval.
469
+ *
470
+ * Utilise un Worker dédié : les navigateurs throttlent agressivement les timers
471
+ * d'un onglet en arrière-plan (jusqu'à 1 tick/minute), ce qui ferait manquer le
472
+ * `heartbeat_timeout` de 60 s du serveur ; un Worker n'est pas soumis à ce
473
+ * throttling. Fallback `setInterval` quand le Worker est indisponible.
474
+ *
475
+ * ── Pourquoi trois filets et pas un ──────────────────────────────────────────
476
+ * Ce battement ne sert plus seulement à tenir la connexion ouverte : c'est aussi
477
+ * lui qui cadence la **détection de connexion morte** du `RealtimeService`. S'il
478
+ * se tait, le client ne ping plus (le serveur coupe à 60 s) *et* ne détecte plus
479
+ * rien. Les trois modes de défaillance d'un Worker sont donc couverts :
480
+ *
481
+ * 1. **Échec synchrone** (`new Worker` lève, `Worker` absent) → `try/catch`.
482
+ * 2. **Échec asynchrone** — le cas d'une CSP `worker-src` qui n'autorise pas
483
+ * `blob:`. La spec HTML est explicite : quand le fetch du script échoue,
484
+ * le constructeur **ne lève pas**, l'agent utilisateur déclenche un événement
485
+ * `error` sur l'objet Worker. Un `try/catch` ne peut donc rien attraper →
486
+ * `onerror` bascule sur le fallback.
487
+ * 3. **Worker inerte** — construit sans erreur mais qui ne poste jamais rien.
488
+ * Aucun événement ne le signale : le Worker accuse donc réception du `start`
489
+ * par un `ready` immédiat, et un chien de garde bascule si cet accusé
490
+ * n'arrive pas.
491
+ *
492
+ * Le chien de garde porte sur le `ready`, PAS sur le premier battement : mesuré
493
+ * en Chromium réel, le démarrage d'un Worker peut dépasser 1,5 intervalle quand
494
+ * l'intervalle est court, ce qui faisait abandonner à tort un Worker
495
+ * parfaitement sain — et perdre silencieusement l'immunité au throttling, la
496
+ * seule raison d'être de ce Worker.
408
497
  */
409
498
  declare class HeartbeatManager {
410
499
  private _worker;
411
500
  private _workerUrl;
412
501
  private _interval;
413
502
  private _sendFn;
503
+ /** Chien de garde de l'accusé `ready` du Worker (mode de défaillance 3). */
504
+ private _watchdog;
505
+ /**
506
+ * Numéro du cycle start/stop courant. Un événement asynchrone (erreur de
507
+ * Worker, chien de garde) portant un numéro périmé appartient à un cycle déjà
508
+ * arrêté ou remplacé : il doit être ignoré, jamais ressusciter un heartbeat.
509
+ */
510
+ private _generation;
511
+ /**
512
+ * Délai accordé au Worker pour accuser réception du `start`.
513
+ *
514
+ * Volontairement **indépendant de l'intervalle de heartbeat** : c'est un temps
515
+ * de démarrage de thread, pas un multiple de la cadence. 3 s est très large
516
+ * pour un Worker sain (quelques millisecondes en pratique) tout en restant
517
+ * loin sous le `heartbeat_timeout` de 60 s du serveur.
518
+ */
519
+ private static readonly WORKER_READY_TIMEOUT_MS;
414
520
  start(sendPing: () => void, intervalMs?: number): void;
521
+ /**
522
+ * Abandonne le Worker et reprend le battement avec un `setInterval`.
523
+ * Idempotent et insensible aux cycles périmés.
524
+ */
525
+ private _fallbackFromWorker;
526
+ private _startFallback;
527
+ private _clearWatchdog;
528
+ /** Termine le Worker courant et libère l'URL d'objet associée. */
529
+ private _disposeWorker;
415
530
  stop(): void;
416
531
  get isActive(): boolean;
417
532
  }
package/dist/index.d.ts CHANGED
@@ -95,6 +95,26 @@ declare class AuraChannel {
95
95
  _bindSend(sendFn: SendFn): void;
96
96
  /** Send the subscribe message to the server. */
97
97
  _doSubscribe(): void;
98
+ /**
99
+ * Notifie le callback de statut d'abonnement, sauf si le canal est fermé.
100
+ *
101
+ * Un canal sorti par `unsubscribe()` est **soldé** : son cycle de vie s'est
102
+ * terminé sur `CLOSED`. Rien ne doit plus le faire parler — ni une erreur
103
+ * serveur globale rediffusée à tous les canaux, ni une confirmation `subscribed`
104
+ * encore en vol au moment du désabonnement. Sans cette garde, l'appelant voyait
105
+ * un `CHANNEL_ERROR` ou un `SUBSCRIBED` APRÈS le `CLOSED` de son propre canal.
106
+ *
107
+ * Parité `@supabase/realtime-js` : un canal quitté est retiré de
108
+ * `socket.channels` (`RealtimeClient#_remove`) et échappe donc par construction
109
+ * à `Socket#triggerChanError` et au routage des réponses. Le SDK Aurabase garde
110
+ * l'instance dans sa Map (`unsubscribe()` ne la retire pas, seul
111
+ * `removeChannel()` le fait), la garde est donc portée par le canal lui-même.
112
+ *
113
+ * `CLOSED` est la seule exception : il est émis précisément au moment où
114
+ * `_subscribed` bascule à `false` — d'où son émission directe dans
115
+ * `unsubscribe()`.
116
+ */
117
+ private _emitStatus;
98
118
  /** (Re)arme le compte à rebours d'attente de la confirmation `subscribed`. */
99
119
  private _armSubscribeTimeout;
100
120
  private _clearSubscribeTimeout;
@@ -251,6 +271,17 @@ declare class RealtimeService {
251
271
  * 30 s et répond au ping : la connexion est morte (TCP à demi-fermé).
252
272
  */
253
273
  private _awaitingPong;
274
+ /** Date du dernier message serveur reçu, pour le filet de liveness. */
275
+ private _lastServerMessageAt;
276
+ /**
277
+ * Filet de liveness, indépendant du `HeartbeatManager`.
278
+ *
279
+ * Sans lui, toute la détection de connexion morte reposait sur le tick du
280
+ * heartbeat — lui-même porté par un Worker. Un Worker tué silencieusement (par
281
+ * la pression mémoire d'un mobile, sans événement `error`) supprimait d'un coup
282
+ * le ping ET la détection : le heartbeat était un point unique de défaillance.
283
+ */
284
+ private _livenessGuard;
254
285
  /** Backoff delays in ms: 1s, 2s, 4s, 8s, 16s, 30s (cap) */
255
286
  private static readonly BACKOFF_MS;
256
287
  constructor(client: AuraClientLike);
@@ -298,6 +329,37 @@ declare class RealtimeService {
298
329
  * compter sur un `onclose` : une socket à demi-fermée n'en émet parfois jamais).
299
330
  */
300
331
  private _detachSocket;
332
+ /**
333
+ * Arme le filet de liveness pour `ws` : il ne juge que sur la date du dernier
334
+ * message reçu, donc il survit à la mort complète du heartbeat.
335
+ *
336
+ * Il vit sur le thread principal, qu'un navigateur throttle en arrière-plan :
337
+ * cela ne peut que le RETARDER, jamais provoquer un faux positif — le verdict
338
+ * porte sur une durée mesurée, pas sur le nombre de ticks écoulés.
339
+ */
340
+ private _startLivenessGuard;
341
+ private _stopLivenessGuard;
342
+ /** Annule le refresh proactif du token, s'il est armé. */
343
+ private _cancelTokenRefresh;
344
+ /**
345
+ * Bascule vers l'état terminal `disconnected` : plus aucune reconnexion n'est
346
+ * prévue, le service doit donc **cesser toute activité de fond**.
347
+ *
348
+ * Le point sensible est le refresh proactif du token. Le laisser courir n'est
349
+ * pas anodin : `auth.refreshSession()` frappe le backend, qui applique une
350
+ * ROTATION STRICTE — chaque refresh révoque l'ancien refresh token
351
+ * (`services/aura-auth/src/services/auth_service.rs`, « Rotation atomique (E2) »),
352
+ * et rejouer un token révoqué au-delà de la fenêtre de grâce déclenche
353
+ * `token_reuse_detected` → **révocation globale de toutes les sessions de
354
+ * l'utilisateur**. Comme `refreshToken()` replanifie systématiquement le timer,
355
+ * un service pourtant `disconnected` faisait tourner les jetons de la session à
356
+ * perpétuité, en tâche de fond, sans que personne ne l'ait demandé.
357
+ *
358
+ * En revanche le refresh est **conservé pendant une reconnexion** : le prochain
359
+ * handshake lit `authStore.token`, et un backoff un peu long laisserait sinon
360
+ * expirer le token — le ré-handshake partirait alors en 401.
361
+ */
362
+ private _goDisconnected;
301
363
  /**
302
364
  * Un tick de heartbeat : vérifie la liveness du lien puis émet un ping.
303
365
  *
@@ -404,14 +466,67 @@ declare class RealtimeService {
404
466
 
405
467
  /**
406
468
  * HeartbeatManager — pings périodiques pour maintenir la connexion WS.
407
- * Utilise un Worker dédié (anti-throttle des onglets en arrière-plan) ; fallback setInterval.
469
+ *
470
+ * Utilise un Worker dédié : les navigateurs throttlent agressivement les timers
471
+ * d'un onglet en arrière-plan (jusqu'à 1 tick/minute), ce qui ferait manquer le
472
+ * `heartbeat_timeout` de 60 s du serveur ; un Worker n'est pas soumis à ce
473
+ * throttling. Fallback `setInterval` quand le Worker est indisponible.
474
+ *
475
+ * ── Pourquoi trois filets et pas un ──────────────────────────────────────────
476
+ * Ce battement ne sert plus seulement à tenir la connexion ouverte : c'est aussi
477
+ * lui qui cadence la **détection de connexion morte** du `RealtimeService`. S'il
478
+ * se tait, le client ne ping plus (le serveur coupe à 60 s) *et* ne détecte plus
479
+ * rien. Les trois modes de défaillance d'un Worker sont donc couverts :
480
+ *
481
+ * 1. **Échec synchrone** (`new Worker` lève, `Worker` absent) → `try/catch`.
482
+ * 2. **Échec asynchrone** — le cas d'une CSP `worker-src` qui n'autorise pas
483
+ * `blob:`. La spec HTML est explicite : quand le fetch du script échoue,
484
+ * le constructeur **ne lève pas**, l'agent utilisateur déclenche un événement
485
+ * `error` sur l'objet Worker. Un `try/catch` ne peut donc rien attraper →
486
+ * `onerror` bascule sur le fallback.
487
+ * 3. **Worker inerte** — construit sans erreur mais qui ne poste jamais rien.
488
+ * Aucun événement ne le signale : le Worker accuse donc réception du `start`
489
+ * par un `ready` immédiat, et un chien de garde bascule si cet accusé
490
+ * n'arrive pas.
491
+ *
492
+ * Le chien de garde porte sur le `ready`, PAS sur le premier battement : mesuré
493
+ * en Chromium réel, le démarrage d'un Worker peut dépasser 1,5 intervalle quand
494
+ * l'intervalle est court, ce qui faisait abandonner à tort un Worker
495
+ * parfaitement sain — et perdre silencieusement l'immunité au throttling, la
496
+ * seule raison d'être de ce Worker.
408
497
  */
409
498
  declare class HeartbeatManager {
410
499
  private _worker;
411
500
  private _workerUrl;
412
501
  private _interval;
413
502
  private _sendFn;
503
+ /** Chien de garde de l'accusé `ready` du Worker (mode de défaillance 3). */
504
+ private _watchdog;
505
+ /**
506
+ * Numéro du cycle start/stop courant. Un événement asynchrone (erreur de
507
+ * Worker, chien de garde) portant un numéro périmé appartient à un cycle déjà
508
+ * arrêté ou remplacé : il doit être ignoré, jamais ressusciter un heartbeat.
509
+ */
510
+ private _generation;
511
+ /**
512
+ * Délai accordé au Worker pour accuser réception du `start`.
513
+ *
514
+ * Volontairement **indépendant de l'intervalle de heartbeat** : c'est un temps
515
+ * de démarrage de thread, pas un multiple de la cadence. 3 s est très large
516
+ * pour un Worker sain (quelques millisecondes en pratique) tout en restant
517
+ * loin sous le `heartbeat_timeout` de 60 s du serveur.
518
+ */
519
+ private static readonly WORKER_READY_TIMEOUT_MS;
414
520
  start(sendPing: () => void, intervalMs?: number): void;
521
+ /**
522
+ * Abandonne le Worker et reprend le battement avec un `setInterval`.
523
+ * Idempotent et insensible aux cycles périmés.
524
+ */
525
+ private _fallbackFromWorker;
526
+ private _startFallback;
527
+ private _clearWatchdog;
528
+ /** Termine le Worker courant et libère l'URL d'objet associée. */
529
+ private _disposeWorker;
415
530
  stop(): void;
416
531
  get isActive(): boolean;
417
532
  }
package/dist/index.js CHANGED
@@ -298,6 +298,29 @@ var AuraChannel = class {
298
298
  this._sendFn(JSON.stringify(msg));
299
299
  this._armSubscribeTimeout();
300
300
  }
301
+ /**
302
+ * Notifie le callback de statut d'abonnement, sauf si le canal est fermé.
303
+ *
304
+ * Un canal sorti par `unsubscribe()` est **soldé** : son cycle de vie s'est
305
+ * terminé sur `CLOSED`. Rien ne doit plus le faire parler — ni une erreur
306
+ * serveur globale rediffusée à tous les canaux, ni une confirmation `subscribed`
307
+ * encore en vol au moment du désabonnement. Sans cette garde, l'appelant voyait
308
+ * un `CHANNEL_ERROR` ou un `SUBSCRIBED` APRÈS le `CLOSED` de son propre canal.
309
+ *
310
+ * Parité `@supabase/realtime-js` : un canal quitté est retiré de
311
+ * `socket.channels` (`RealtimeClient#_remove`) et échappe donc par construction
312
+ * à `Socket#triggerChanError` et au routage des réponses. Le SDK Aurabase garde
313
+ * l'instance dans sa Map (`unsubscribe()` ne la retire pas, seul
314
+ * `removeChannel()` le fait), la garde est donc portée par le canal lui-même.
315
+ *
316
+ * `CLOSED` est la seule exception : il est émis précisément au moment où
317
+ * `_subscribed` bascule à `false` — d'où son émission directe dans
318
+ * `unsubscribe()`.
319
+ */
320
+ _emitStatus(status, err) {
321
+ if (!this._subscribed) return;
322
+ this._subscribeCallback?.(status, err);
323
+ }
301
324
  /** (Re)arme le compte à rebours d'attente de la confirmation `subscribed`. */
302
325
  _armSubscribeTimeout() {
303
326
  this._clearSubscribeTimeout();
@@ -324,8 +347,7 @@ var AuraChannel = class {
324
347
  */
325
348
  _signalError(err) {
326
349
  this._clearSubscribeTimeout();
327
- if (!this._subscribed) return;
328
- this._subscribeCallback?.("CHANNEL_ERROR", err);
350
+ this._emitStatus("CHANNEL_ERROR", err);
329
351
  }
330
352
  /** Unsubscribe from the channel. */
331
353
  unsubscribe() {
@@ -421,7 +443,7 @@ var AuraChannel = class {
421
443
  const subMsg = msg;
422
444
  if (subMsg.subscription_id) this._subscriptionId = subMsg.subscription_id;
423
445
  this._clearSubscribeTimeout();
424
- this._subscribeCallback?.("SUBSCRIBED");
446
+ this._emitStatus("SUBSCRIBED");
425
447
  break;
426
448
  }
427
449
  case "event": {
@@ -440,6 +462,7 @@ var AuraChannel = class {
440
462
  break;
441
463
  }
442
464
  case "presence_state": {
465
+ if (!this._subscribed) break;
443
466
  const presMsg = msg;
444
467
  if (presMsg.presences) {
445
468
  this._presenceState = {};
@@ -459,7 +482,7 @@ var AuraChannel = class {
459
482
  case "error": {
460
483
  const errMsg = msg;
461
484
  this._clearSubscribeTimeout();
462
- this._subscribeCallback?.("CHANNEL_ERROR", new Error(errMsg.message ?? "Unknown error"));
485
+ this._emitStatus("CHANNEL_ERROR", new Error(errMsg.message ?? "Unknown error"));
463
486
  break;
464
487
  }
465
488
  case "token_refreshed":
@@ -599,44 +622,87 @@ var AuraChannel = class {
599
622
  };
600
623
 
601
624
  // src/HeartbeatManager.ts
602
- var HeartbeatManager = class {
625
+ var HeartbeatManager = class _HeartbeatManager {
603
626
  _worker = null;
604
627
  _workerUrl = null;
605
628
  _interval = null;
606
629
  _sendFn = null;
630
+ /** Chien de garde de l'accusé `ready` du Worker (mode de défaillance 3). */
631
+ _watchdog = null;
632
+ /**
633
+ * Numéro du cycle start/stop courant. Un événement asynchrone (erreur de
634
+ * Worker, chien de garde) portant un numéro périmé appartient à un cycle déjà
635
+ * arrêté ou remplacé : il doit être ignoré, jamais ressusciter un heartbeat.
636
+ */
637
+ _generation = 0;
638
+ /**
639
+ * Délai accordé au Worker pour accuser réception du `start`.
640
+ *
641
+ * Volontairement **indépendant de l'intervalle de heartbeat** : c'est un temps
642
+ * de démarrage de thread, pas un multiple de la cadence. 3 s est très large
643
+ * pour un Worker sain (quelques millisecondes en pratique) tout en restant
644
+ * loin sous le `heartbeat_timeout` de 60 s du serveur.
645
+ */
646
+ static WORKER_READY_TIMEOUT_MS = 3e3;
607
647
  start(sendPing, intervalMs = 25e3) {
608
648
  this.stop();
649
+ const generation = ++this._generation;
609
650
  this._sendFn = sendPing;
610
651
  if (typeof Worker !== "undefined" && typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
611
652
  try {
612
- const code = `let i=null;onmessage=(e)=>{if(e.data.type==='start'){if(i)clearInterval(i);i=setInterval(()=>postMessage('ping'),e.data.interval||25000);}else if(e.data.type==='stop'){if(i){clearInterval(i);i=null;}}};`;
653
+ const code = `let i=null;onmessage=(e)=>{if(e.data.type==='start'){if(i)clearInterval(i);postMessage('ready');i=setInterval(()=>postMessage('ping'),e.data.interval||25000);}else if(e.data.type==='stop'){if(i){clearInterval(i);i=null;}}};`;
613
654
  const blob = new Blob([code], { type: "application/javascript" });
614
655
  this._workerUrl = URL.createObjectURL(blob);
615
- this._worker = new Worker(this._workerUrl);
616
- this._worker.onmessage = () => {
656
+ const worker = new Worker(this._workerUrl);
657
+ this._worker = worker;
658
+ worker.onmessage = (evt) => {
659
+ if (generation !== this._generation) return;
660
+ this._clearWatchdog();
661
+ if (evt?.data === "ready") return;
617
662
  this._sendFn?.();
618
663
  };
619
- this._worker.postMessage({ type: "start", interval: intervalMs });
664
+ worker.onerror = () => {
665
+ this._fallbackFromWorker(generation, intervalMs);
666
+ };
667
+ worker.postMessage({ type: "start", interval: intervalMs });
668
+ this._watchdog = setTimeout(
669
+ () => this._fallbackFromWorker(generation, intervalMs),
670
+ _HeartbeatManager.WORKER_READY_TIMEOUT_MS
671
+ );
620
672
  return;
621
673
  } catch {
622
- this._worker = null;
623
- if (this._workerUrl) {
624
- try {
625
- URL.revokeObjectURL(this._workerUrl);
626
- } catch {
627
- }
628
- this._workerUrl = null;
629
- }
674
+ this._disposeWorker();
630
675
  }
631
676
  }
677
+ this._startFallback(intervalMs);
678
+ }
679
+ /**
680
+ * Abandonne le Worker et reprend le battement avec un `setInterval`.
681
+ * Idempotent et insensible aux cycles périmés.
682
+ */
683
+ _fallbackFromWorker(generation, intervalMs) {
684
+ if (generation !== this._generation) return;
685
+ if (this._interval) return;
686
+ if (!this._sendFn) return;
687
+ this._clearWatchdog();
688
+ this._disposeWorker();
689
+ this._startFallback(intervalMs);
690
+ }
691
+ _startFallback(intervalMs) {
632
692
  this._interval = setInterval(() => {
633
693
  this._sendFn?.();
634
694
  }, intervalMs);
635
695
  }
636
- stop() {
696
+ _clearWatchdog() {
697
+ if (this._watchdog) {
698
+ clearTimeout(this._watchdog);
699
+ this._watchdog = null;
700
+ }
701
+ }
702
+ /** Termine le Worker courant et libère l'URL d'objet associée. */
703
+ _disposeWorker() {
637
704
  if (this._worker) {
638
705
  try {
639
- this._worker.postMessage({ type: "stop" });
640
706
  this._worker.terminate();
641
707
  } catch {
642
708
  }
@@ -649,6 +715,17 @@ var HeartbeatManager = class {
649
715
  }
650
716
  this._workerUrl = null;
651
717
  }
718
+ }
719
+ stop() {
720
+ this._generation++;
721
+ this._clearWatchdog();
722
+ if (this._worker) {
723
+ try {
724
+ this._worker.postMessage({ type: "stop" });
725
+ } catch {
726
+ }
727
+ }
728
+ this._disposeWorker();
652
729
  if (this._interval) {
653
730
  clearInterval(this._interval);
654
731
  this._interval = null;
@@ -709,6 +786,8 @@ var RealtimeConnectionError = class _RealtimeConnectionError extends Error {
709
786
  // src/RealtimeService.ts
710
787
  var HEARTBEAT_INTERVAL_MS = 25e3;
711
788
  var MAX_TIMEOUT_MS = 2147483647;
789
+ var LIVENESS_GUARD_INTERVAL_MS = 3e4;
790
+ var LIVENESS_MAX_SILENCE_MS = 9e4;
712
791
  var RealtimeService = class _RealtimeService {
713
792
  client;
714
793
  _ws = null;
@@ -734,6 +813,17 @@ var RealtimeService = class _RealtimeService {
734
813
  * 30 s et répond au ping : la connexion est morte (TCP à demi-fermé).
735
814
  */
736
815
  _awaitingPong = false;
816
+ /** Date du dernier message serveur reçu, pour le filet de liveness. */
817
+ _lastServerMessageAt = 0;
818
+ /**
819
+ * Filet de liveness, indépendant du `HeartbeatManager`.
820
+ *
821
+ * Sans lui, toute la détection de connexion morte reposait sur le tick du
822
+ * heartbeat — lui-même porté par un Worker. Un Worker tué silencieusement (par
823
+ * la pression mémoire d'un mobile, sans événement `error`) supprimait d'un coup
824
+ * le ping ET la détection : le heartbeat était un point unique de défaillance.
825
+ */
826
+ _livenessGuard = null;
737
827
  /** Backoff delays in ms: 1s, 2s, 4s, 8s, 16s, 30s (cap) */
738
828
  static BACKOFF_MS = [1e3, 2e3, 4e3, 8e3, 16e3, 3e4];
739
829
  constructor(client) {
@@ -838,7 +928,9 @@ var RealtimeService = class _RealtimeService {
838
928
  this._reconnectAttempts = 0;
839
929
  this._setState("connected");
840
930
  this._awaitingPong = false;
931
+ this._lastServerMessageAt = Date.now();
841
932
  this._heartbeat.start(() => this._heartbeatTick(ws), HEARTBEAT_INTERVAL_MS);
933
+ this._startLivenessGuard(ws);
842
934
  this._scheduleTokenRefresh();
843
935
  for (const channel of this._channels.values()) {
844
936
  channel._bindSend((data) => this._send(data));
@@ -850,6 +942,7 @@ var RealtimeService = class _RealtimeService {
850
942
  ws.onmessage = (evt) => {
851
943
  if (ws !== this._ws) return;
852
944
  this._awaitingPong = false;
945
+ this._lastServerMessageAt = Date.now();
853
946
  try {
854
947
  const raw = typeof evt.data === "string" ? evt.data : "";
855
948
  const msg = JSON.parse(raw);
@@ -878,12 +971,12 @@ var RealtimeService = class _RealtimeService {
878
971
  );
879
972
  }
880
973
  if (intentional || this._intentionalClose) {
881
- this._setState("disconnected");
974
+ this._goDisconnected();
882
975
  this._intentionalClose = false;
883
976
  } else if (this._autoReconnect) {
884
977
  this._scheduleReconnect();
885
978
  } else {
886
- this._setState("disconnected");
979
+ this._goDisconnected();
887
980
  }
888
981
  };
889
982
  ws.onerror = (evt) => {
@@ -903,6 +996,61 @@ var RealtimeService = class _RealtimeService {
903
996
  this._lastSentToken = null;
904
997
  this._awaitingPong = false;
905
998
  this._heartbeat.stop();
999
+ this._stopLivenessGuard();
1000
+ }
1001
+ /**
1002
+ * Arme le filet de liveness pour `ws` : il ne juge que sur la date du dernier
1003
+ * message reçu, donc il survit à la mort complète du heartbeat.
1004
+ *
1005
+ * Il vit sur le thread principal, qu'un navigateur throttle en arrière-plan :
1006
+ * cela ne peut que le RETARDER, jamais provoquer un faux positif — le verdict
1007
+ * porte sur une durée mesurée, pas sur le nombre de ticks écoulés.
1008
+ */
1009
+ _startLivenessGuard(ws) {
1010
+ this._stopLivenessGuard();
1011
+ this._livenessGuard = setInterval(() => {
1012
+ if (ws !== this._ws) return;
1013
+ const silence = Date.now() - this._lastServerMessageAt;
1014
+ if (silence < LIVENESS_MAX_SILENCE_MS) return;
1015
+ this._handleDeadConnection(
1016
+ `Realtime : aucun message du serveur depuis ${Math.round(silence / 1e3)} s alors qu'il en pousse un toutes les 30 s \u2014 connexion consid\xE9r\xE9e morte. Reconnexion.`
1017
+ );
1018
+ }, LIVENESS_GUARD_INTERVAL_MS);
1019
+ }
1020
+ _stopLivenessGuard() {
1021
+ if (this._livenessGuard) {
1022
+ clearInterval(this._livenessGuard);
1023
+ this._livenessGuard = null;
1024
+ }
1025
+ }
1026
+ /** Annule le refresh proactif du token, s'il est armé. */
1027
+ _cancelTokenRefresh() {
1028
+ if (this._tokenRefreshTimer) {
1029
+ clearTimeout(this._tokenRefreshTimer);
1030
+ this._tokenRefreshTimer = null;
1031
+ }
1032
+ }
1033
+ /**
1034
+ * Bascule vers l'état terminal `disconnected` : plus aucune reconnexion n'est
1035
+ * prévue, le service doit donc **cesser toute activité de fond**.
1036
+ *
1037
+ * Le point sensible est le refresh proactif du token. Le laisser courir n'est
1038
+ * pas anodin : `auth.refreshSession()` frappe le backend, qui applique une
1039
+ * ROTATION STRICTE — chaque refresh révoque l'ancien refresh token
1040
+ * (`services/aura-auth/src/services/auth_service.rs`, « Rotation atomique (E2) »),
1041
+ * et rejouer un token révoqué au-delà de la fenêtre de grâce déclenche
1042
+ * `token_reuse_detected` → **révocation globale de toutes les sessions de
1043
+ * l'utilisateur**. Comme `refreshToken()` replanifie systématiquement le timer,
1044
+ * un service pourtant `disconnected` faisait tourner les jetons de la session à
1045
+ * perpétuité, en tâche de fond, sans que personne ne l'ait demandé.
1046
+ *
1047
+ * En revanche le refresh est **conservé pendant une reconnexion** : le prochain
1048
+ * handshake lit `authStore.token`, et un backoff un peu long laisserait sinon
1049
+ * expirer le token — le ré-handshake partirait alors en 401.
1050
+ */
1051
+ _goDisconnected() {
1052
+ this._cancelTokenRefresh();
1053
+ this._setState("disconnected");
906
1054
  }
907
1055
  /**
908
1056
  * Un tick de heartbeat : vérifie la liveness du lien puis émet un ping.
@@ -953,7 +1101,7 @@ var RealtimeService = class _RealtimeService {
953
1101
  this._scheduleReconnect();
954
1102
  } else {
955
1103
  this._intentionalClose = false;
956
- this._setState("disconnected");
1104
+ this._goDisconnected();
957
1105
  }
958
1106
  }
959
1107
  /** Send raw data through the WebSocket. */
@@ -1033,10 +1181,7 @@ var RealtimeService = class _RealtimeService {
1033
1181
  * Prevents token expiration during an active WebSocket session.
1034
1182
  */
1035
1183
  _scheduleTokenRefresh() {
1036
- if (this._tokenRefreshTimer) {
1037
- clearTimeout(this._tokenRefreshTimer);
1038
- this._tokenRefreshTimer = null;
1039
- }
1184
+ this._cancelTokenRefresh();
1040
1185
  const token = this.client.authStore.token;
1041
1186
  if (!token) return;
1042
1187
  const exp = getTokenExpiryMs(token);
@@ -1069,7 +1214,7 @@ var RealtimeService = class _RealtimeService {
1069
1214
  this._reconnectTimer = null;
1070
1215
  }
1071
1216
  if (this._reconnectAttempts >= this._maxReconnectAttempts) {
1072
- this._setState("disconnected");
1217
+ this._goDisconnected();
1073
1218
  return;
1074
1219
  }
1075
1220
  this._reconnectAttempts++;
@@ -1092,10 +1237,8 @@ var RealtimeService = class _RealtimeService {
1092
1237
  disconnect() {
1093
1238
  this._intentionalClose = true;
1094
1239
  this._heartbeat.stop();
1095
- if (this._tokenRefreshTimer) {
1096
- clearTimeout(this._tokenRefreshTimer);
1097
- this._tokenRefreshTimer = null;
1098
- }
1240
+ this._stopLivenessGuard();
1241
+ this._cancelTokenRefresh();
1099
1242
  if (this._reconnectTimer) {
1100
1243
  clearTimeout(this._reconnectTimer);
1101
1244
  this._reconnectTimer = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurabase/realtime",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Service temps réel du SDK Aurabase (postgres_changes, broadcast, presence)",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -45,9 +45,10 @@
45
45
  "realtime"
46
46
  ],
47
47
  "dependencies": {
48
- "@aurabase/core": "0.4.0"
48
+ "@aurabase/core": "0.4.1"
49
49
  },
50
50
  "devDependencies": {
51
+ "@types/node": "20.14.15",
51
52
  "tsup": "^8.0.0",
52
53
  "typescript": "^5.5.0",
53
54
  "vitest": "^4.1.6"
@@ -55,6 +56,7 @@
55
56
  "scripts": {
56
57
  "build": "tsup",
57
58
  "test": "vitest run --passWithNoTests",
58
- "typecheck": "tsc --noEmit"
59
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
60
+ "typecheck:tests": "tsc --noEmit -p tsconfig.test.json"
59
61
  }
60
62
  }