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