@aurabase/realtime 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,27 @@
1
- import { fetchWithAuth } from '@aurabase/core';
2
-
3
- // src/RealtimeService.ts
1
+ import { AuraError, fetchWithAuth } from '@aurabase/core';
4
2
 
5
3
  // src/columnFilter.ts
4
+ function logicalSchema(physical) {
5
+ if (!physical.startsWith("project_")) return physical;
6
+ const rest = physical.slice("project_".length);
7
+ if (rest.length < 36) return physical;
8
+ switch (rest.slice(36)) {
9
+ case "":
10
+ return "public";
11
+ case "_auth":
12
+ return "auth";
13
+ case "_storage":
14
+ return "storage";
15
+ case "_platform":
16
+ return "platform";
17
+ default:
18
+ return physical;
19
+ }
20
+ }
21
+ function schemaMatches(filterSchema, payloadSchema) {
22
+ if (typeof payloadSchema !== "string") return false;
23
+ return filterSchema === payloadSchema || filterSchema === logicalSchema(payloadSchema);
24
+ }
6
25
  var VALID_OPS = /* @__PURE__ */ new Set([
7
26
  "eq",
8
27
  "neq",
@@ -43,8 +62,11 @@ function parseColumnFilter(filterStr) {
43
62
  value = stripped;
44
63
  } else if (op === "is") {
45
64
  if (value !== "null" && value !== "true" && value !== "false") return null;
65
+ } else if (op === "like" || op === "ilike") {
66
+ if (value.length > MAX_REGEX_PATTERN_LEN) return null;
46
67
  } else if (op === "match" || op === "imatch") {
47
68
  if (value.length > MAX_REGEX_PATTERN_LEN) return null;
69
+ if (hasCatastrophicBacktrackingRisk(value)) return null;
48
70
  try {
49
71
  new RegExp(value, op === "imatch" ? "i" : void 0);
50
72
  } catch {
@@ -100,6 +122,12 @@ function compareNumeric(a, b, cmp) {
100
122
  if (an === null || bn === null) return false;
101
123
  return cmp(an, bn);
102
124
  }
125
+ function hasCatastrophicBacktrackingRisk(pattern) {
126
+ const QUANT = "(?:[+*]|\\{\\d*,\\d*\\})";
127
+ if (new RegExp(`\\([^()]*${QUANT}[^()]*\\)${QUANT}`).test(pattern)) return true;
128
+ if (new RegExp(`\\([^()]*\\|[^()]*\\)${QUANT}`).test(pattern)) return true;
129
+ return false;
130
+ }
103
131
  function likeToRegExp(pattern, caseInsensitive) {
104
132
  let re = "^";
105
133
  for (const ch of pattern) {
@@ -191,9 +219,10 @@ function matchesColumnFilter(filter, record) {
191
219
 
192
220
  // src/AuraChannel.ts
193
221
  var DEFAULT_SUBSCRIBE_TIMEOUT_MS = 1e4;
222
+ var PERTE_D_ABONNEMENT = /Non abonné au canal|Accès révoqué au channel/;
194
223
  var AuraChannel = class {
195
224
  name;
196
- _projectId;
225
+ _projectPrefix;
197
226
  _listeners = [];
198
227
  _subscribed = false;
199
228
  _sendFn = null;
@@ -209,8 +238,18 @@ var AuraChannel = class {
209
238
  _refCounter = 0;
210
239
  /** Dernier event ID recu — utilise pour since_id au reconnect */
211
240
  _lastEventId = null;
212
- /** Pending ACK callbacks : ref_id → resolve */
241
+ /** Pending ACK callbacks : ref_id → solde la promesse de `send()` avec un statut. */
213
242
  _pendingAcks = /* @__PURE__ */ new Map();
243
+ /**
244
+ * Abonnement vu par le SERVEUR — à ne pas confondre avec `_subscribed`, qui n'est que
245
+ * l'INTENTION de l'appelant (et pilote le re-subscribe après reconnexion).
246
+ *
247
+ * C18 (rejeu) : `_subscribed` était posé optimistement par `subscribe()` et n'était
248
+ * jamais infirmé quand le serveur REFUSAIT l'abonnement. `send({ ack })` armait alors un
249
+ * compte à rebours de 5 s pour un accusé structurellement impossible — mesuré sur le banc
250
+ * à 5 065 ms, alors que le serveur avait répondu en 6 ms « Non abonné au canal … ».
251
+ */
252
+ _serverState = "pending";
214
253
  /**
215
254
  * Timer d'attente de la confirmation `subscribed`. Armé à l'ENVOI effectif du
216
255
  * message subscribe (parité phoenix : `Push#startTimeout` ne démarre qu'au
@@ -221,9 +260,16 @@ var AuraChannel = class {
221
260
  _subscribeTimeoutMs = DEFAULT_SUBSCRIBE_TIMEOUT_MS;
222
261
  /** `CLOSED` n'est émis qu'une fois par cycle de vie du canal (unsubscribe idempotent). */
223
262
  _closedEmitted = false;
224
- constructor(name, projectId, options) {
263
+ /**
264
+ * @param projectPrefix Préfixe de projet du canal sur le fil (`"<project_id>:"`, ou `""`
265
+ * s'il est inconnu), résolu PARESSEUSEMENT : le SDK ne connaît le projet qu'à travers
266
+ * le token de session, qui peut arriver après la création du canal (connexion
267
+ * différée). Une valeur figée à la construction serait alors périmée pour toujours,
268
+ * le canal étant mémorisé par `RealtimeService`.
269
+ */
270
+ constructor(name, projectPrefix, options) {
225
271
  this.name = name;
226
- this._projectId = projectId;
272
+ this._projectPrefix = projectPrefix;
227
273
  this._isPrivate = options?.private ?? false;
228
274
  this._broadcastAck = options?.broadcast?.ack ?? false;
229
275
  this._broadcastSelf = options?.broadcast?.self ?? false;
@@ -235,7 +281,7 @@ var AuraChannel = class {
235
281
  }
236
282
  /** Full channel name with project prefix (for server protocol) */
237
283
  get fullName() {
238
- return `${this._projectId}:${this.name}`;
284
+ return `${this._projectPrefix()}${this.name}`;
239
285
  }
240
286
  on(type, filter, callback) {
241
287
  this._listeners.push({ type, filter, callback });
@@ -295,9 +341,33 @@ var AuraChannel = class {
295
341
  private: this._isPrivate
296
342
  }
297
343
  };
344
+ this._serverState = "pending";
298
345
  this._sendFn(JSON.stringify(msg));
299
346
  this._armSubscribeTimeout();
300
347
  }
348
+ /**
349
+ * Notifie le callback de statut d'abonnement, sauf si le canal est fermé.
350
+ *
351
+ * Un canal sorti par `unsubscribe()` est **soldé** : son cycle de vie s'est
352
+ * terminé sur `CLOSED`. Rien ne doit plus le faire parler — ni une erreur
353
+ * serveur globale rediffusée à tous les canaux, ni une confirmation `subscribed`
354
+ * encore en vol au moment du désabonnement. Sans cette garde, l'appelant voyait
355
+ * un `CHANNEL_ERROR` ou un `SUBSCRIBED` APRÈS le `CLOSED` de son propre canal.
356
+ *
357
+ * Parité `@supabase/realtime-js` : un canal quitté est retiré de
358
+ * `socket.channels` (`RealtimeClient#_remove`) et échappe donc par construction
359
+ * à `Socket#triggerChanError` et au routage des réponses. Le SDK Aurabase garde
360
+ * l'instance dans sa Map (`unsubscribe()` ne la retire pas, seul
361
+ * `removeChannel()` le fait), la garde est donc portée par le canal lui-même.
362
+ *
363
+ * `CLOSED` est la seule exception : il est émis précisément au moment où
364
+ * `_subscribed` bascule à `false` — d'où son émission directe dans
365
+ * `unsubscribe()`.
366
+ */
367
+ _emitStatus(status, err) {
368
+ if (!this._subscribed) return;
369
+ this._subscribeCallback?.(status, err);
370
+ }
301
371
  /** (Re)arme le compte à rebours d'attente de la confirmation `subscribed`. */
302
372
  _armSubscribeTimeout() {
303
373
  this._clearSubscribeTimeout();
@@ -324,8 +394,7 @@ var AuraChannel = class {
324
394
  */
325
395
  _signalError(err) {
326
396
  this._clearSubscribeTimeout();
327
- if (!this._subscribed) return;
328
- this._subscribeCallback?.("CHANNEL_ERROR", err);
397
+ this._emitStatus("CHANNEL_ERROR", err);
329
398
  }
330
399
  /** Unsubscribe from the channel. */
331
400
  unsubscribe() {
@@ -343,45 +412,84 @@ var AuraChannel = class {
343
412
  }
344
413
  // ─── Broadcast ──────────────────────────────────────────────────────────
345
414
  /**
346
- * Send a broadcast message to the channel.
347
- * If broadcast.ack=true, returns a Promise that resolves when the server ACKs.
415
+ * Envoie un message broadcast sur le canal.
416
+ *
417
+ * Avec `broadcast: { ack: true }`, la promesse ne se résout qu'à l'accusé de réception du
418
+ * serveur (`broadcast_ack`) ; sans `ack`, elle se résout dès la remise au transport.
419
+ *
420
+ * Le statut rendu nomme sa CAUSE — voir {@link BroadcastSendStatus} :
421
+ * `'ok'`, `'timeout'`, `'not_connected'`, `'requires_subscription'`, `'error'`.
422
+ *
423
+ * **`'requires_subscription'`** signifie que le serveur n'a pas d'abonnement pour ce canal
424
+ * — soit `.subscribe()` n'a jamais été appelé, soit le serveur l'a REFUSÉ (RLS,
425
+ * `private_only`, quota) ou révoqué depuis. Rien n'est émis dans ce cas : le serveur
426
+ * rejetterait l'écriture (`Non abonné au canal '…' — Subscribe requis avant Broadcast`).
348
427
  *
349
428
  * @example
350
429
  * ```ts
351
- * // Without ACK (fire & forget)
430
+ * // Sans ACK (fire & forget)
352
431
  * channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100, y: 200 } })
353
432
  *
354
- * // With ACK (wait for server confirmation)
355
- * await channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100 } })
433
+ * // Avec ACK — le modèle est celui de Supabase : on écrit APRÈS SUBSCRIBED.
434
+ * channel.subscribe(async (statut) => {
435
+ * if (statut !== 'SUBSCRIBED') return // CHANNEL_ERROR = le serveur a refusé ce canal
436
+ * const status = await channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100 } })
437
+ * if (status === 'requires_subscription') {
438
+ * // l'abonnement a été refusé ou révoqué entre-temps ; la cause a été remontée en
439
+ * // CHANNEL_ERROR à ce même callback.
440
+ * }
441
+ * })
356
442
  * ```
357
443
  */
358
444
  send(message) {
359
445
  return new Promise((resolve) => {
360
446
  if (!this._sendFn) {
361
- resolve("error");
447
+ resolve("not_connected");
448
+ return;
449
+ }
450
+ if (!this._subscribed || this._serverState === "refused") {
451
+ resolve("requires_subscription");
362
452
  return;
363
453
  }
364
454
  const ref_id = message.ref_id ?? this._nextRef();
365
- if (this._broadcastAck) {
366
- const timer = setTimeout(() => {
455
+ const ackExpected = this._broadcastAck;
456
+ let ackTimer = null;
457
+ if (ackExpected) {
458
+ ackTimer = setTimeout(() => {
367
459
  this._pendingAcks.delete(ref_id);
368
460
  resolve("timeout");
369
461
  }, 5e3);
370
- this._pendingAcks.set(ref_id, () => {
371
- clearTimeout(timer);
372
- resolve("ok");
462
+ this._pendingAcks.set(ref_id, (status) => {
463
+ if (ackTimer) clearTimeout(ackTimer);
464
+ resolve(status);
373
465
  });
374
- } else {
375
- resolve("ok");
376
466
  }
377
- this._sendFn(
378
- JSON.stringify({
379
- type: "broadcast",
380
- channel: this.fullName,
381
- ref_id,
382
- payload: { event: message.event, ...message.payload }
383
- })
384
- );
467
+ const cancelAck = () => {
468
+ if (ackTimer) clearTimeout(ackTimer);
469
+ this._pendingAcks.delete(ref_id);
470
+ };
471
+ let delivered;
472
+ try {
473
+ delivered = this._sendFn(
474
+ JSON.stringify({
475
+ type: "broadcast",
476
+ channel: this.fullName,
477
+ ref_id,
478
+ payload: { event: message.event, ...message.payload }
479
+ })
480
+ ) !== false;
481
+ } catch {
482
+ cancelAck();
483
+ resolve("error");
484
+ return;
485
+ }
486
+ if (!delivered) {
487
+ cancelAck();
488
+ resolve("not_connected");
489
+ return;
490
+ }
491
+ if (ackExpected) return;
492
+ resolve("ok");
385
493
  });
386
494
  }
387
495
  // ─── Presence ───────────────────────────────────────────────────────────
@@ -394,19 +502,25 @@ var AuraChannel = class {
394
502
  * await channel.track({ user: 'user-1', online_at: new Date().toISOString() })
395
503
  * ```
396
504
  */
397
- async track(state) {
398
- if (!this._sendFn) return "error";
399
- this._sendFn(
400
- JSON.stringify({
401
- type: "presence_update",
402
- channel: this.fullName,
403
- meta: state
404
- })
405
- );
406
- return "ok";
505
+ track(state) {
506
+ if (!this._sendFn) return Promise.resolve("error");
507
+ if (!this._subscribed || this._serverState === "refused") return Promise.resolve("error");
508
+ try {
509
+ const delivered = this._sendFn(
510
+ JSON.stringify({
511
+ type: "presence_update",
512
+ channel: this.fullName,
513
+ meta: state
514
+ })
515
+ ) !== false;
516
+ if (!delivered) return Promise.resolve("error");
517
+ } catch {
518
+ return Promise.resolve("error");
519
+ }
520
+ return Promise.resolve("ok");
407
521
  }
408
- /** Stop tracking presence. */
409
- async untrack() {
522
+ /** Stop tracking presence. Même contrat que `track()`. */
523
+ untrack() {
410
524
  return this.track({});
411
525
  }
412
526
  /** Get the current presence state for this channel. */
@@ -420,8 +534,9 @@ var AuraChannel = class {
420
534
  case "subscribed": {
421
535
  const subMsg = msg;
422
536
  if (subMsg.subscription_id) this._subscriptionId = subMsg.subscription_id;
537
+ this._serverState = "subscribed";
423
538
  this._clearSubscribeTimeout();
424
- this._subscribeCallback?.("SUBSCRIBED");
539
+ this._emitStatus("SUBSCRIBED");
425
540
  break;
426
541
  }
427
542
  case "event": {
@@ -440,6 +555,7 @@ var AuraChannel = class {
440
555
  break;
441
556
  }
442
557
  case "presence_state": {
558
+ if (!this._subscribed) break;
443
559
  const presMsg = msg;
444
560
  if (presMsg.presences) {
445
561
  this._presenceState = {};
@@ -458,8 +574,18 @@ var AuraChannel = class {
458
574
  }
459
575
  case "error": {
460
576
  const errMsg = msg;
577
+ const errChannel = errMsg.channel;
578
+ if (errChannel && errChannel !== this.fullName && errChannel !== this.name) {
579
+ break;
580
+ }
461
581
  this._clearSubscribeTimeout();
462
- this._subscribeCallback?.("CHANNEL_ERROR", new Error(errMsg.message ?? "Unknown error"));
582
+ this._emitStatus("CHANNEL_ERROR", new Error(errMsg.message ?? "Unknown error"));
583
+ const nommeCeCanal = errChannel !== void 0;
584
+ const perteDAbonnement = nommeCeCanal && (this._serverState !== "subscribed" || PERTE_D_ABONNEMENT.test(errMsg.message ?? ""));
585
+ if (perteDAbonnement) {
586
+ this._serverState = "refused";
587
+ this._settlePendingAcks("requires_subscription");
588
+ }
463
589
  break;
464
590
  }
465
591
  case "token_refreshed":
@@ -468,16 +594,29 @@ var AuraChannel = class {
468
594
  const ackMsg = msg;
469
595
  const refId = ackMsg.ref_id ?? ackMsg.message_id;
470
596
  if (refId) {
471
- const resolve = this._pendingAcks.get(refId);
472
- if (resolve) {
597
+ const settle = this._pendingAcks.get(refId);
598
+ if (settle) {
473
599
  this._pendingAcks.delete(refId);
474
- resolve();
600
+ settle("ok");
475
601
  }
476
602
  }
477
603
  break;
478
604
  }
479
605
  }
480
606
  }
607
+ /**
608
+ * Solde TOUS les accusés en attente de ce canal avec un statut donné.
609
+ *
610
+ * Le protocole ne corrèle pas l'erreur serveur au `ref_id` de l'écriture refusée : quand
611
+ * le serveur déclare n'avoir aucun abonnement pour ce canal, AUCUN accusé en vol ne peut
612
+ * plus aboutir — les solder tous est donc exact, pas approximatif.
613
+ */
614
+ _settlePendingAcks(status) {
615
+ if (this._pendingAcks.size === 0) return;
616
+ const settlers = Array.from(this._pendingAcks.values());
617
+ this._pendingAcks.clear();
618
+ for (const settle of settlers) settle(status);
619
+ }
481
620
  /** Dispatch a RealtimeEvent to matching listeners. */
482
621
  _dispatchEvent(event) {
483
622
  if (!event) return;
@@ -490,51 +629,49 @@ var AuraChannel = class {
490
629
  if (eventType === "presence") {
491
630
  const payload = event.payload;
492
631
  const action = payload.action;
493
- const key = payload.user_id || payload.client_id;
632
+ const clientId = payload.client_id;
633
+ const locate = () => {
634
+ for (const [key, arr] of Object.entries(this._presenceState)) {
635
+ const index = arr.findIndex((p) => p.client_id === clientId);
636
+ if (index >= 0) return { key, index };
637
+ }
638
+ return null;
639
+ };
494
640
  if (action === "join") {
495
- if (!this._presenceState[key]) this._presenceState[key] = [];
496
- if (!this._presenceState[key].some((p) => p.client_id === payload.client_id)) {
497
- const presenceInfo = {
498
- client_id: payload.client_id,
641
+ const key = payload.user_id || clientId;
642
+ if (!locate()) {
643
+ if (!this._presenceState[key]) this._presenceState[key] = [];
644
+ this._presenceState[key].push({
645
+ client_id: clientId,
499
646
  user_id: payload.user_id ?? void 0,
500
647
  metadata: payload.metadata ?? payload.meta ?? {},
501
648
  joined_at: (/* @__PURE__ */ new Date()).toISOString(),
502
649
  last_seen: (/* @__PURE__ */ new Date()).toISOString()
503
- };
504
- this._presenceState[key].push(presenceInfo);
650
+ });
505
651
  }
506
652
  } else if (action === "leave") {
507
- const arr = this._presenceState[key];
508
- if (arr) {
509
- const idx = arr.findIndex((p) => p.client_id === payload.client_id);
510
- if (idx >= 0) arr.splice(idx, 1);
511
- if (arr.length === 0) delete this._presenceState[key];
653
+ const found = locate();
654
+ if (found) {
655
+ const arr = this._presenceState[found.key];
656
+ arr.splice(found.index, 1);
657
+ if (arr.length === 0) delete this._presenceState[found.key];
512
658
  }
513
659
  } else if (action === "update") {
514
- const arr = this._presenceState[key];
515
- if (arr) {
516
- const idx = arr.findIndex((p) => p.client_id === payload.client_id);
517
- if (idx >= 0) {
518
- arr[idx].metadata = payload.metadata ?? payload.meta ?? {};
519
- arr[idx].last_seen = (/* @__PURE__ */ new Date()).toISOString();
520
- } else {
521
- const presenceInfo = {
522
- client_id: payload.client_id,
523
- user_id: payload.user_id ?? void 0,
524
- metadata: payload.metadata ?? payload.meta ?? {},
525
- joined_at: (/* @__PURE__ */ new Date()).toISOString(),
526
- last_seen: (/* @__PURE__ */ new Date()).toISOString()
527
- };
528
- this._presenceState[key].push(presenceInfo);
529
- }
660
+ const found = locate();
661
+ if (found) {
662
+ const entry = this._presenceState[found.key][found.index];
663
+ entry.metadata = payload.metadata ?? payload.meta ?? {};
664
+ entry.last_seen = (/* @__PURE__ */ new Date()).toISOString();
530
665
  } else {
531
- this._presenceState[key] = [{
532
- client_id: payload.client_id,
666
+ const key = payload.user_id || clientId;
667
+ if (!this._presenceState[key]) this._presenceState[key] = [];
668
+ this._presenceState[key].push({
669
+ client_id: clientId,
533
670
  user_id: payload.user_id ?? void 0,
534
671
  metadata: payload.metadata ?? payload.meta ?? {},
535
672
  joined_at: (/* @__PURE__ */ new Date()).toISOString(),
536
673
  last_seen: (/* @__PURE__ */ new Date()).toISOString()
537
- }];
674
+ });
538
675
  }
539
676
  }
540
677
  }
@@ -545,7 +682,7 @@ var AuraChannel = class {
545
682
  const filterEvent = filter.event.toLowerCase();
546
683
  if (filterEvent === "*" || filterEvent === eventType) {
547
684
  const payload = event.payload;
548
- if (filter.schema && payload.schema !== filter.schema) continue;
685
+ if (filter.schema && !schemaMatches(filter.schema, payload.schema)) continue;
549
686
  if (filter.table && payload.table !== filter.table) continue;
550
687
  if (filter.filter) {
551
688
  const target = eventType === "delete" ? payload.old_record : payload.record;
@@ -598,45 +735,106 @@ var AuraChannel = class {
598
735
  }
599
736
  };
600
737
 
738
+ // src/errors.ts
739
+ var RealtimeConnectionError = class _RealtimeConnectionError extends Error {
740
+ /** Phase du cycle de vie où l'échec s'est produit. */
741
+ phase;
742
+ /** Code de fermeture WebSocket (RFC 6455 §7.4), si le runtime l'a fourni. */
743
+ code;
744
+ /** Raison de fermeture fournie par le serveur, si présente (souvent vide). */
745
+ reason;
746
+ constructor(phase, message, options) {
747
+ super(message, options?.cause !== void 0 ? { cause: options.cause } : void 0);
748
+ this.name = "RealtimeConnectionError";
749
+ this.phase = phase;
750
+ this.code = options?.code;
751
+ this.reason = options?.reason;
752
+ Object.setPrototypeOf(this, _RealtimeConnectionError.prototype);
753
+ }
754
+ };
755
+
601
756
  // src/HeartbeatManager.ts
602
- var HeartbeatManager = class {
757
+ var HeartbeatManager = class _HeartbeatManager {
603
758
  _worker = null;
604
759
  _workerUrl = null;
605
760
  _interval = null;
606
761
  _sendFn = null;
762
+ /** Chien de garde de l'accusé `ready` du Worker (mode de défaillance 3). */
763
+ _watchdog = null;
764
+ /**
765
+ * Numéro du cycle start/stop courant. Un événement asynchrone (erreur de
766
+ * Worker, chien de garde) portant un numéro périmé appartient à un cycle déjà
767
+ * arrêté ou remplacé : il doit être ignoré, jamais ressusciter un heartbeat.
768
+ */
769
+ _generation = 0;
770
+ /**
771
+ * Délai accordé au Worker pour accuser réception du `start`.
772
+ *
773
+ * Volontairement **indépendant de l'intervalle de heartbeat** : c'est un temps
774
+ * de démarrage de thread, pas un multiple de la cadence. 3 s est très large
775
+ * pour un Worker sain (quelques millisecondes en pratique) tout en restant
776
+ * loin sous le `heartbeat_timeout` de 60 s du serveur.
777
+ */
778
+ static WORKER_READY_TIMEOUT_MS = 3e3;
607
779
  start(sendPing, intervalMs = 25e3) {
608
780
  this.stop();
781
+ const generation = ++this._generation;
609
782
  this._sendFn = sendPing;
610
783
  if (typeof Worker !== "undefined" && typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
611
784
  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;}}};`;
785
+ 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
786
  const blob = new Blob([code], { type: "application/javascript" });
614
787
  this._workerUrl = URL.createObjectURL(blob);
615
- this._worker = new Worker(this._workerUrl);
616
- this._worker.onmessage = () => {
788
+ const worker = new Worker(this._workerUrl);
789
+ this._worker = worker;
790
+ worker.onmessage = (evt) => {
791
+ if (generation !== this._generation) return;
792
+ this._clearWatchdog();
793
+ if (evt?.data === "ready") return;
617
794
  this._sendFn?.();
618
795
  };
619
- this._worker.postMessage({ type: "start", interval: intervalMs });
796
+ worker.onerror = () => {
797
+ this._fallbackFromWorker(generation, intervalMs);
798
+ };
799
+ worker.postMessage({ type: "start", interval: intervalMs });
800
+ this._watchdog = setTimeout(
801
+ () => this._fallbackFromWorker(generation, intervalMs),
802
+ _HeartbeatManager.WORKER_READY_TIMEOUT_MS
803
+ );
620
804
  return;
621
805
  } catch {
622
- this._worker = null;
623
- if (this._workerUrl) {
624
- try {
625
- URL.revokeObjectURL(this._workerUrl);
626
- } catch {
627
- }
628
- this._workerUrl = null;
629
- }
806
+ this._disposeWorker();
630
807
  }
631
808
  }
809
+ this._startFallback(intervalMs);
810
+ }
811
+ /**
812
+ * Abandonne le Worker et reprend le battement avec un `setInterval`.
813
+ * Idempotent et insensible aux cycles périmés.
814
+ */
815
+ _fallbackFromWorker(generation, intervalMs) {
816
+ if (generation !== this._generation) return;
817
+ if (this._interval) return;
818
+ if (!this._sendFn) return;
819
+ this._clearWatchdog();
820
+ this._disposeWorker();
821
+ this._startFallback(intervalMs);
822
+ }
823
+ _startFallback(intervalMs) {
632
824
  this._interval = setInterval(() => {
633
825
  this._sendFn?.();
634
826
  }, intervalMs);
635
827
  }
636
- stop() {
828
+ _clearWatchdog() {
829
+ if (this._watchdog) {
830
+ clearTimeout(this._watchdog);
831
+ this._watchdog = null;
832
+ }
833
+ }
834
+ /** Termine le Worker courant et libère l'URL d'objet associée. */
835
+ _disposeWorker() {
637
836
  if (this._worker) {
638
837
  try {
639
- this._worker.postMessage({ type: "stop" });
640
838
  this._worker.terminate();
641
839
  } catch {
642
840
  }
@@ -649,6 +847,17 @@ var HeartbeatManager = class {
649
847
  }
650
848
  this._workerUrl = null;
651
849
  }
850
+ }
851
+ stop() {
852
+ this._generation++;
853
+ this._clearWatchdog();
854
+ if (this._worker) {
855
+ try {
856
+ this._worker.postMessage({ type: "stop" });
857
+ } catch {
858
+ }
859
+ }
860
+ this._disposeWorker();
652
861
  if (this._interval) {
653
862
  clearInterval(this._interval);
654
863
  this._interval = null;
@@ -687,28 +896,18 @@ function getTokenExpiryMs(token) {
687
896
  if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
688
897
  return exp * 1e3;
689
898
  }
690
-
691
- // src/errors.ts
692
- var RealtimeConnectionError = class _RealtimeConnectionError extends Error {
693
- /** Phase du cycle de vie où l'échec s'est produit. */
694
- phase;
695
- /** Code de fermeture WebSocket (RFC 6455 §7.4), si le runtime l'a fourni. */
696
- code;
697
- /** Raison de fermeture fournie par le serveur, si présente (souvent vide). */
698
- reason;
699
- constructor(phase, message, options) {
700
- super(message, options?.cause !== void 0 ? { cause: options.cause } : void 0);
701
- this.name = "RealtimeConnectionError";
702
- this.phase = phase;
703
- this.code = options?.code;
704
- this.reason = options?.reason;
705
- Object.setPrototypeOf(this, _RealtimeConnectionError.prototype);
706
- }
707
- };
899
+ function getTokenProjectId(token) {
900
+ const payload = decodeJwtPayload(token);
901
+ const pid = payload?.project_id;
902
+ return typeof pid === "string" && pid !== "" ? pid : null;
903
+ }
708
904
 
709
905
  // src/RealtimeService.ts
710
906
  var HEARTBEAT_INTERVAL_MS = 25e3;
711
907
  var MAX_TIMEOUT_MS = 2147483647;
908
+ var LIVENESS_GUARD_INTERVAL_MS = 3e4;
909
+ var CANAL_QUALIFIE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}:/;
910
+ var LIVENESS_MAX_SILENCE_MS = 9e4;
712
911
  var RealtimeService = class _RealtimeService {
713
912
  client;
714
913
  _ws = null;
@@ -734,6 +933,17 @@ var RealtimeService = class _RealtimeService {
734
933
  * 30 s et répond au ping : la connexion est morte (TCP à demi-fermé).
735
934
  */
736
935
  _awaitingPong = false;
936
+ /** Date du dernier message serveur reçu, pour le filet de liveness. */
937
+ _lastServerMessageAt = 0;
938
+ /**
939
+ * Filet de liveness, indépendant du `HeartbeatManager`.
940
+ *
941
+ * Sans lui, toute la détection de connexion morte reposait sur le tick du
942
+ * heartbeat — lui-même porté par un Worker. Un Worker tué silencieusement (par
943
+ * la pression mémoire d'un mobile, sans événement `error`) supprimait d'un coup
944
+ * le ping ET la détection : le heartbeat était un point unique de défaillance.
945
+ */
946
+ _livenessGuard = null;
737
947
  /** Backoff delays in ms: 1s, 2s, 4s, 8s, 16s, 30s (cap) */
738
948
  static BACKOFF_MS = [1e3, 2e3, 4e3, 8e3, 16e3, 3e4];
739
949
  constructor(client) {
@@ -805,6 +1015,23 @@ var RealtimeService = class _RealtimeService {
805
1015
  }
806
1016
  }
807
1017
  }
1018
+ // ─── Préfixe de projet des canaux ───────────────────────────────────────
1019
+ /**
1020
+ * Préfixe `"<project_id>:"` des noms de canaux sur le fil, `""` s'il est inconnu.
1021
+ *
1022
+ * `aura-realtime` exige que tout canal commence par le `project_id` du TOKEN
1023
+ * (`handlers/websocket.rs`, `expected_prefix` ; idem pour le broadcast HTTP,
1024
+ * `handlers/broadcast.rs`). Le SDK ne porte plus d'identifiant de projet — le projet est
1025
+ * celui de l'URL — mais le token de session, lui, porte ce claim : on le lit là, dans le
1026
+ * même jeton que celui envoyé au serveur. Sans token (pas encore connecté), il n'y a rien
1027
+ * à préfixer ; la connexion WS échouerait de toute façon, le serveur exigeant un JWT.
1028
+ */
1029
+ _projectPrefix() {
1030
+ const token = this.client.authStore.token;
1031
+ if (!token) return "";
1032
+ const pid = getTokenProjectId(token);
1033
+ return pid ? `${pid}:` : "";
1034
+ }
808
1035
  // ─── WebSocket URL ──────────────────────────────────────────────────────
809
1036
  get _wsUrl() {
810
1037
  const base = this.client.baseUrl.replace(/^http/, "ws");
@@ -838,7 +1065,9 @@ var RealtimeService = class _RealtimeService {
838
1065
  this._reconnectAttempts = 0;
839
1066
  this._setState("connected");
840
1067
  this._awaitingPong = false;
1068
+ this._lastServerMessageAt = Date.now();
841
1069
  this._heartbeat.start(() => this._heartbeatTick(ws), HEARTBEAT_INTERVAL_MS);
1070
+ this._startLivenessGuard(ws);
842
1071
  this._scheduleTokenRefresh();
843
1072
  for (const channel of this._channels.values()) {
844
1073
  channel._bindSend((data) => this._send(data));
@@ -850,6 +1079,7 @@ var RealtimeService = class _RealtimeService {
850
1079
  ws.onmessage = (evt) => {
851
1080
  if (ws !== this._ws) return;
852
1081
  this._awaitingPong = false;
1082
+ this._lastServerMessageAt = Date.now();
853
1083
  try {
854
1084
  const raw = typeof evt.data === "string" ? evt.data : "";
855
1085
  const msg = JSON.parse(raw);
@@ -878,12 +1108,12 @@ var RealtimeService = class _RealtimeService {
878
1108
  );
879
1109
  }
880
1110
  if (intentional || this._intentionalClose) {
881
- this._setState("disconnected");
1111
+ this._goDisconnected();
882
1112
  this._intentionalClose = false;
883
1113
  } else if (this._autoReconnect) {
884
1114
  this._scheduleReconnect();
885
1115
  } else {
886
- this._setState("disconnected");
1116
+ this._goDisconnected();
887
1117
  }
888
1118
  };
889
1119
  ws.onerror = (evt) => {
@@ -903,6 +1133,61 @@ var RealtimeService = class _RealtimeService {
903
1133
  this._lastSentToken = null;
904
1134
  this._awaitingPong = false;
905
1135
  this._heartbeat.stop();
1136
+ this._stopLivenessGuard();
1137
+ }
1138
+ /**
1139
+ * Arme le filet de liveness pour `ws` : il ne juge que sur la date du dernier
1140
+ * message reçu, donc il survit à la mort complète du heartbeat.
1141
+ *
1142
+ * Il vit sur le thread principal, qu'un navigateur throttle en arrière-plan :
1143
+ * cela ne peut que le RETARDER, jamais provoquer un faux positif — le verdict
1144
+ * porte sur une durée mesurée, pas sur le nombre de ticks écoulés.
1145
+ */
1146
+ _startLivenessGuard(ws) {
1147
+ this._stopLivenessGuard();
1148
+ this._livenessGuard = setInterval(() => {
1149
+ if (ws !== this._ws) return;
1150
+ const silence = Date.now() - this._lastServerMessageAt;
1151
+ if (silence < LIVENESS_MAX_SILENCE_MS) return;
1152
+ this._handleDeadConnection(
1153
+ `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.`
1154
+ );
1155
+ }, LIVENESS_GUARD_INTERVAL_MS);
1156
+ }
1157
+ _stopLivenessGuard() {
1158
+ if (this._livenessGuard) {
1159
+ clearInterval(this._livenessGuard);
1160
+ this._livenessGuard = null;
1161
+ }
1162
+ }
1163
+ /** Annule le refresh proactif du token, s'il est armé. */
1164
+ _cancelTokenRefresh() {
1165
+ if (this._tokenRefreshTimer) {
1166
+ clearTimeout(this._tokenRefreshTimer);
1167
+ this._tokenRefreshTimer = null;
1168
+ }
1169
+ }
1170
+ /**
1171
+ * Bascule vers l'état terminal `disconnected` : plus aucune reconnexion n'est
1172
+ * prévue, le service doit donc **cesser toute activité de fond**.
1173
+ *
1174
+ * Le point sensible est le refresh proactif du token. Le laisser courir n'est
1175
+ * pas anodin : `auth.refreshSession()` frappe le backend, qui applique une
1176
+ * ROTATION STRICTE — chaque refresh révoque l'ancien refresh token
1177
+ * (`services/aura-auth/src/services/auth_service.rs`, « Rotation atomique (E2) »),
1178
+ * et rejouer un token révoqué au-delà de la fenêtre de grâce déclenche
1179
+ * `token_reuse_detected` → **révocation globale de toutes les sessions de
1180
+ * l'utilisateur**. Comme `refreshToken()` replanifie systématiquement le timer,
1181
+ * un service pourtant `disconnected` faisait tourner les jetons de la session à
1182
+ * perpétuité, en tâche de fond, sans que personne ne l'ait demandé.
1183
+ *
1184
+ * En revanche le refresh est **conservé pendant une reconnexion** : le prochain
1185
+ * handshake lit `authStore.token`, et un backoff un peu long laisserait sinon
1186
+ * expirer le token — le ré-handshake partirait alors en 401.
1187
+ */
1188
+ _goDisconnected() {
1189
+ this._cancelTokenRefresh();
1190
+ this._setState("disconnected");
906
1191
  }
907
1192
  /**
908
1193
  * Un tick de heartbeat : vérifie la liveness du lien puis émet un ping.
@@ -953,14 +1238,24 @@ var RealtimeService = class _RealtimeService {
953
1238
  this._scheduleReconnect();
954
1239
  } else {
955
1240
  this._intentionalClose = false;
956
- this._setState("disconnected");
1241
+ this._goDisconnected();
957
1242
  }
958
1243
  }
959
- /** Send raw data through the WebSocket. */
1244
+ /**
1245
+ * Send raw data through the WebSocket.
1246
+ *
1247
+ * @returns `true` si le message a été remis à la socket, `false` s'il a été **jeté**
1248
+ * (socket absente ou non-OPEN). Ce retour n'est pas cosmétique : sans lui,
1249
+ * `AuraChannel.send()`/`track()` rendaient `'ok'` pour un message qui n'avait jamais
1250
+ * quitté le process — la seule branche de `send()` capable de rendre `'error'` était
1251
+ * une exception que ce `_send` ne lève jamais (C18/N24).
1252
+ */
960
1253
  _send(data) {
961
1254
  if (this._ws && this._ws.readyState === WebSocket.OPEN) {
962
1255
  this._ws.send(data);
1256
+ return true;
963
1257
  }
1258
+ return false;
964
1259
  }
965
1260
  /** Refresh the JWT token on an existing connection (no disconnect). */
966
1261
  refreshToken(token) {
@@ -1001,29 +1296,72 @@ var RealtimeService = class _RealtimeService {
1001
1296
  * Envoie un broadcast par HTTP (REST), sans connexion WebSocket ouverte
1002
1297
  * (parité `@supabase/realtime-js`). Utile côté serveur/edge.
1003
1298
  *
1004
- * POST `/v1/realtime/{projectId}/broadcast` avec le contrat du handler backend
1299
+ * POST `/v1/realtime/broadcast` avec le contrat du handler backend
1005
1300
  * (`broadcast.rs`) : `{ channel, payload }`, `channel` préfixé par le projet,
1006
1301
  * `event` replié dans `payload` (aligné sur le broadcast WS).
1007
1302
  *
1008
- * @param channel Nom du canal (préfixé projet automatiquement si absent).
1303
+ * ### Le canal DOIT porter le projet — y compris hors session (K2)
1304
+ *
1305
+ * Le handler exige `channel.starts_with("{project_id}:")` et répond sinon
1306
+ * `403 « Le canal n'appartient pas à ce projet »`
1307
+ * (`services/aura-realtime/src/handlers/broadcast.rs`, fonction `broadcast`).
1308
+ *
1309
+ * Avec une session, ce préfixe est ajouté ici même : il est lu dans le claim
1310
+ * `project_id` du jeton (voir {@link _projectPrefix}).
1311
+ *
1312
+ * **Sans session — le cas d'usage serveur/edge de cette méthode** — le SDK n'a AUCUN
1313
+ * identifiant de projet : l'URL est la seule désignation du tenant côté client, et rien
1314
+ * ne la traduit en `project_id` (c'est la passerelle qui fait cette résolution, à partir
1315
+ * de l'hôte). L'authentification, elle, passe : pour le realtime, la passerelle forge un
1316
+ * JWT interne à partir des claims de la clé API et écrase l'`Authorization`
1317
+ * (`aura-gateway/src/proxy/sse_proxy.rs::do_http_proxy`). Émettre un canal nu produisait
1318
+ * donc un `403` dont le message ne nomme jamais la forme attendue.
1319
+ *
1320
+ * Cette méthode **refuse** désormais ce cas, sans appel réseau
1321
+ * (`error.code === 'realtime_channel_non_qualifie'`) : passez le canal déjà qualifié,
1322
+ * `"<project_id>:<canal>"`. Cette forme est acceptée dans les deux situations — avec une
1323
+ * session, elle n'est pas préfixée une seconde fois.
1324
+ *
1325
+ * @param channel Nom du canal. Avec une session : nom nu ou déjà qualifié. Sans session :
1326
+ * OBLIGATOIREMENT `"<project_id>:<canal>"`.
1009
1327
  * @param event Nom de l'événement broadcast.
1010
1328
  * @param payload Données de l'événement.
1011
- * @returns `AuraResponse<null>` — `error` non nul en cas de 4xx/rate-limit.
1329
+ * @returns `AuraResponse<null>` — `error` non nul en cas de 4xx/rate-limit, ou de canal
1330
+ * non qualifiable (refus local, aucun appel réseau).
1012
1331
  *
1013
1332
  * @example
1014
1333
  * ```ts
1334
+ * // Navigateur, utilisateur connecté : le projet vient du jeton de session.
1015
1335
  * await aura.realtime.httpBroadcast('room-1', 'cursor', { x: 10, y: 20 })
1336
+ *
1337
+ * // Serveur / edge (clé service_role, aucune session) : qualifier le canal.
1338
+ * await aura.realtime.httpBroadcast(`${PROJECT_ID}:room-1`, 'cursor', { x: 10, y: 20 })
1016
1339
  * ```
1017
1340
  */
1018
1341
  httpBroadcast(channel, event, payload = {}) {
1019
- const prefix = `${this.client.projectId}:`;
1020
- const fullChannel = channel.startsWith(prefix) ? channel : `${prefix}${channel}`;
1342
+ const prefix = this._projectPrefix();
1343
+ let fullChannel;
1344
+ if (prefix) {
1345
+ fullChannel = channel.startsWith(prefix) ? channel : `${prefix}${channel}`;
1346
+ } else if (CANAL_QUALIFIE.test(channel)) {
1347
+ fullChannel = channel;
1348
+ } else {
1349
+ return Promise.resolve({
1350
+ data: null,
1351
+ error: new AuraError(0, {
1352
+ error: {
1353
+ code: "realtime_channel_non_qualifie",
1354
+ message: `httpBroadcast: le canal "${channel}" n'est pas qualifi\xE9 et aucune session n'est ouverte \u2014 le SDK n'a donc aucun \`project_id\` \xE0 lui pr\xE9fixer, alors que le serveur l'EXIGE (403 \xAB Le canal n'appartient pas \xE0 ce projet \xBB). Passez le canal sous la forme "<project_id>:` + channel + `" \u2014 c'est la forme attendue c\xF4t\xE9 serveur/edge, o\xF9 il n'y a pas de session utilisateur.`
1355
+ }
1356
+ })
1357
+ });
1358
+ }
1021
1359
  const body = {
1022
1360
  channel: fullChannel,
1023
1361
  payload: { event, ...payload }
1024
1362
  };
1025
1363
  return fetchWithAuth(
1026
- `${this.client.baseUrl}/v1/realtime/${this.client.projectId}/broadcast`,
1364
+ `${this.client.baseUrl}/v1/realtime/broadcast`,
1027
1365
  { method: "POST", body },
1028
1366
  this.client
1029
1367
  );
@@ -1033,10 +1371,7 @@ var RealtimeService = class _RealtimeService {
1033
1371
  * Prevents token expiration during an active WebSocket session.
1034
1372
  */
1035
1373
  _scheduleTokenRefresh() {
1036
- if (this._tokenRefreshTimer) {
1037
- clearTimeout(this._tokenRefreshTimer);
1038
- this._tokenRefreshTimer = null;
1039
- }
1374
+ this._cancelTokenRefresh();
1040
1375
  const token = this.client.authStore.token;
1041
1376
  if (!token) return;
1042
1377
  const exp = getTokenExpiryMs(token);
@@ -1069,7 +1404,7 @@ var RealtimeService = class _RealtimeService {
1069
1404
  this._reconnectTimer = null;
1070
1405
  }
1071
1406
  if (this._reconnectAttempts >= this._maxReconnectAttempts) {
1072
- this._setState("disconnected");
1407
+ this._goDisconnected();
1073
1408
  return;
1074
1409
  }
1075
1410
  this._reconnectAttempts++;
@@ -1092,10 +1427,8 @@ var RealtimeService = class _RealtimeService {
1092
1427
  disconnect() {
1093
1428
  this._intentionalClose = true;
1094
1429
  this._heartbeat.stop();
1095
- if (this._tokenRefreshTimer) {
1096
- clearTimeout(this._tokenRefreshTimer);
1097
- this._tokenRefreshTimer = null;
1098
- }
1430
+ this._stopLivenessGuard();
1431
+ this._cancelTokenRefresh();
1099
1432
  if (this._reconnectTimer) {
1100
1433
  clearTimeout(this._reconnectTimer);
1101
1434
  this._reconnectTimer = null;
@@ -1126,7 +1459,7 @@ var RealtimeService = class _RealtimeService {
1126
1459
  channel(name, options) {
1127
1460
  const existing = this._channels.get(name);
1128
1461
  if (existing) return existing;
1129
- const ch = new AuraChannel(name, this.client.projectId, options);
1462
+ const ch = new AuraChannel(name, () => this._projectPrefix(), options);
1130
1463
  this._channels.set(name, ch);
1131
1464
  this.connect();
1132
1465
  if (this._ws && this._ws.readyState === WebSocket.OPEN) {
@@ -1158,8 +1491,8 @@ var RealtimeService = class _RealtimeService {
1158
1491
  _dispatch(msg) {
1159
1492
  const channelFull = "channel" in msg ? msg.channel : "topic" in msg ? msg.topic : void 0;
1160
1493
  if (channelFull) {
1161
- const prefix = `${this.client.projectId}:`;
1162
- const name = channelFull.startsWith(prefix) ? channelFull.slice(prefix.length) : channelFull;
1494
+ const prefix = this._projectPrefix();
1495
+ const name = prefix && channelFull.startsWith(prefix) ? channelFull.slice(prefix.length) : channelFull;
1163
1496
  const ch = this._channels.get(name);
1164
1497
  if (ch) {
1165
1498
  ch._onMessage(msg);
@@ -1171,6 +1504,15 @@ var RealtimeService = class _RealtimeService {
1171
1504
  return;
1172
1505
  }
1173
1506
  }
1507
+ if (msg.type === "error") {
1508
+ if (typeof console !== "undefined") {
1509
+ console.warn(
1510
+ "[aura-realtime] Erreur serveur pour un canal inconnu de ce client \u2014 ignor\xE9e (aucun canal local concern\xE9) :",
1511
+ { channel: channelFull, message: msg.message }
1512
+ );
1513
+ }
1514
+ return;
1515
+ }
1174
1516
  }
1175
1517
  if (msg.type === "token_refreshed") {
1176
1518
  return;
@@ -1204,22 +1546,6 @@ var RealtimeService = class _RealtimeService {
1204
1546
  }
1205
1547
  }
1206
1548
  }
1207
- // ─── Backward compatibility ─────────────────────────────────────────────
1208
- /**
1209
- * Simple callback-based subscription (legacy API).
1210
- * Prefer using `.channel().on().subscribe()` instead.
1211
- *
1212
- * @deprecated Use `channel().on().subscribe()` instead.
1213
- */
1214
- on(channelName, callback) {
1215
- this.connect();
1216
- const ch = this.channel(channelName);
1217
- ch.on("postgres_changes", { event: "*" }, callback);
1218
- ch.subscribe();
1219
- return {
1220
- unsubscribe: () => this.removeChannel(channelName)
1221
- };
1222
- }
1223
1549
  };
1224
1550
 
1225
1551
  export { AuraChannel, DEFAULT_SUBSCRIBE_TIMEOUT_MS, HEARTBEAT_INTERVAL_MS, HeartbeatManager, RealtimeConnectionError, RealtimeService };