@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 +15 -0
- package/dist/index.cjs +174 -31
- package/dist/index.d.cts +116 -1
- package/dist/index.d.ts +116 -1
- package/dist/index.js +174 -31
- package/package.json +5 -3
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
618
|
-
this._worker
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
976
|
+
this._goDisconnected();
|
|
884
977
|
this._intentionalClose = false;
|
|
885
978
|
} else if (this._autoReconnect) {
|
|
886
979
|
this._scheduleReconnect();
|
|
887
980
|
} else {
|
|
888
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1098
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
616
|
-
this._worker
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
974
|
+
this._goDisconnected();
|
|
882
975
|
this._intentionalClose = false;
|
|
883
976
|
} else if (this._autoReconnect) {
|
|
884
977
|
this._scheduleReconnect();
|
|
885
978
|
} else {
|
|
886
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1096
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|