@aurabase/realtime 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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,13 +260,22 @@ 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;
227
- this._isPrivate = options?.private ?? false;
228
- this._broadcastAck = options?.broadcast?.ack ?? false;
229
- this._broadcastSelf = options?.broadcast?.self ?? false;
230
- this._presenceKey = options?.config?.presence?.key ?? "";
272
+ this._projectPrefix = projectPrefix;
273
+ const bcast = options?.broadcast ?? options?.config?.broadcast;
274
+ const pres = options?.presence ?? options?.config?.presence;
275
+ this._isPrivate = Boolean(options?.private ?? options?.config?.private ?? false);
276
+ this._broadcastAck = Boolean(bcast?.ack ?? false);
277
+ this._broadcastSelf = Boolean(bcast?.self ?? false);
278
+ this._presenceKey = typeof pres?.key === "string" ? pres.key : "";
231
279
  }
232
280
  /** Génère un ref_id unique pour la corrélation request/response. */
233
281
  _nextRef() {
@@ -235,7 +283,7 @@ var AuraChannel = class {
235
283
  }
236
284
  /** Full channel name with project prefix (for server protocol) */
237
285
  get fullName() {
238
- return `${this._projectId}:${this.name}`;
286
+ return `${this._projectPrefix()}${this.name}`;
239
287
  }
240
288
  on(type, filter, callback) {
241
289
  this._listeners.push({ type, filter, callback });
@@ -260,6 +308,7 @@ var AuraChannel = class {
260
308
  subscribe(callback, timeoutMs) {
261
309
  this._subscribeCallback = callback ?? null;
262
310
  this._subscribed = true;
311
+ this._serverState = "pending";
263
312
  this._closedEmitted = false;
264
313
  if (typeof timeoutMs === "number" && timeoutMs > 0) {
265
314
  this._subscribeTimeoutMs = timeoutMs;
@@ -295,6 +344,7 @@ var AuraChannel = class {
295
344
  private: this._isPrivate
296
345
  }
297
346
  };
347
+ this._serverState = "pending";
298
348
  this._sendFn(JSON.stringify(msg));
299
349
  this._armSubscribeTimeout();
300
350
  }
@@ -365,45 +415,84 @@ var AuraChannel = class {
365
415
  }
366
416
  // ─── Broadcast ──────────────────────────────────────────────────────────
367
417
  /**
368
- * Send a broadcast message to the channel.
369
- * If broadcast.ack=true, returns a Promise that resolves when the server ACKs.
418
+ * Envoie un message broadcast sur le canal.
419
+ *
420
+ * Avec `broadcast: { ack: true }`, la promesse ne se résout qu'à l'accusé de réception du
421
+ * serveur (`broadcast_ack`) ; sans `ack`, elle se résout dès la remise au transport.
422
+ *
423
+ * Le statut rendu nomme sa CAUSE — voir {@link BroadcastSendStatus} :
424
+ * `'ok'`, `'timeout'`, `'not_connected'`, `'requires_subscription'`, `'error'`.
425
+ *
426
+ * **`'requires_subscription'`** signifie que le serveur n'a pas d'abonnement pour ce canal
427
+ * — soit `.subscribe()` n'a jamais été appelé, soit le serveur l'a REFUSÉ (RLS,
428
+ * `private_only`, quota) ou révoqué depuis. Rien n'est émis dans ce cas : le serveur
429
+ * rejetterait l'écriture (`Non abonné au canal '…' — Subscribe requis avant Broadcast`).
370
430
  *
371
431
  * @example
372
432
  * ```ts
373
- * // Without ACK (fire & forget)
433
+ * // Sans ACK (fire & forget)
374
434
  * channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100, y: 200 } })
375
435
  *
376
- * // With ACK (wait for server confirmation)
377
- * await channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100 } })
436
+ * // Avec ACK — le modèle est celui de Supabase : on écrit APRÈS SUBSCRIBED.
437
+ * channel.subscribe(async (statut) => {
438
+ * if (statut !== 'SUBSCRIBED') return // CHANNEL_ERROR = le serveur a refusé ce canal
439
+ * const status = await channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100 } })
440
+ * if (status === 'requires_subscription') {
441
+ * // l'abonnement a été refusé ou révoqué entre-temps ; la cause a été remontée en
442
+ * // CHANNEL_ERROR à ce même callback.
443
+ * }
444
+ * })
378
445
  * ```
379
446
  */
380
447
  send(message) {
381
448
  return new Promise((resolve) => {
382
449
  if (!this._sendFn) {
383
- resolve("error");
450
+ resolve("not_connected");
451
+ return;
452
+ }
453
+ if (!this._subscribed || this._serverState === "refused") {
454
+ resolve("requires_subscription");
384
455
  return;
385
456
  }
386
457
  const ref_id = message.ref_id ?? this._nextRef();
387
- if (this._broadcastAck) {
388
- const timer = setTimeout(() => {
458
+ const ackExpected = this._broadcastAck;
459
+ let ackTimer = null;
460
+ if (ackExpected) {
461
+ ackTimer = setTimeout(() => {
389
462
  this._pendingAcks.delete(ref_id);
390
463
  resolve("timeout");
391
464
  }, 5e3);
392
- this._pendingAcks.set(ref_id, () => {
393
- clearTimeout(timer);
394
- resolve("ok");
465
+ this._pendingAcks.set(ref_id, (status) => {
466
+ if (ackTimer) clearTimeout(ackTimer);
467
+ resolve(status);
395
468
  });
396
- } else {
397
- resolve("ok");
398
469
  }
399
- this._sendFn(
400
- JSON.stringify({
401
- type: "broadcast",
402
- channel: this.fullName,
403
- ref_id,
404
- payload: { event: message.event, ...message.payload }
405
- })
406
- );
470
+ const cancelAck = () => {
471
+ if (ackTimer) clearTimeout(ackTimer);
472
+ this._pendingAcks.delete(ref_id);
473
+ };
474
+ let delivered;
475
+ try {
476
+ delivered = this._sendFn(
477
+ JSON.stringify({
478
+ type: "broadcast",
479
+ channel: this.fullName,
480
+ ref_id,
481
+ payload: { event: message.event, ...message.payload }
482
+ })
483
+ ) !== false;
484
+ } catch {
485
+ cancelAck();
486
+ resolve("error");
487
+ return;
488
+ }
489
+ if (!delivered) {
490
+ cancelAck();
491
+ resolve("not_connected");
492
+ return;
493
+ }
494
+ if (ackExpected) return;
495
+ resolve("ok");
407
496
  });
408
497
  }
409
498
  // ─── Presence ───────────────────────────────────────────────────────────
@@ -416,19 +505,25 @@ var AuraChannel = class {
416
505
  * await channel.track({ user: 'user-1', online_at: new Date().toISOString() })
417
506
  * ```
418
507
  */
419
- async track(state) {
420
- if (!this._sendFn) return "error";
421
- this._sendFn(
422
- JSON.stringify({
423
- type: "presence_update",
424
- channel: this.fullName,
425
- meta: state
426
- })
427
- );
428
- return "ok";
508
+ track(state) {
509
+ if (!this._sendFn) return Promise.resolve("error");
510
+ if (!this._subscribed || this._serverState === "refused") return Promise.resolve("error");
511
+ try {
512
+ const delivered = this._sendFn(
513
+ JSON.stringify({
514
+ type: "presence_update",
515
+ channel: this.fullName,
516
+ meta: state
517
+ })
518
+ ) !== false;
519
+ if (!delivered) return Promise.resolve("error");
520
+ } catch {
521
+ return Promise.resolve("error");
522
+ }
523
+ return Promise.resolve("ok");
429
524
  }
430
- /** Stop tracking presence. */
431
- async untrack() {
525
+ /** Stop tracking presence. Même contrat que `track()`. */
526
+ untrack() {
432
527
  return this.track({});
433
528
  }
434
529
  /** Get the current presence state for this channel. */
@@ -442,6 +537,7 @@ var AuraChannel = class {
442
537
  case "subscribed": {
443
538
  const subMsg = msg;
444
539
  if (subMsg.subscription_id) this._subscriptionId = subMsg.subscription_id;
540
+ this._serverState = "subscribed";
445
541
  this._clearSubscribeTimeout();
446
542
  this._emitStatus("SUBSCRIBED");
447
543
  break;
@@ -481,8 +577,18 @@ var AuraChannel = class {
481
577
  }
482
578
  case "error": {
483
579
  const errMsg = msg;
580
+ const errChannel = errMsg.channel;
581
+ if (errChannel && errChannel !== this.fullName && errChannel !== this.name) {
582
+ break;
583
+ }
484
584
  this._clearSubscribeTimeout();
485
585
  this._emitStatus("CHANNEL_ERROR", new Error(errMsg.message ?? "Unknown error"));
586
+ const nommeCeCanal = errChannel !== void 0;
587
+ const perteDAbonnement = nommeCeCanal && (this._serverState !== "subscribed" || PERTE_D_ABONNEMENT.test(errMsg.message ?? ""));
588
+ if (perteDAbonnement) {
589
+ this._serverState = "refused";
590
+ this._settlePendingAcks("requires_subscription");
591
+ }
486
592
  break;
487
593
  }
488
594
  case "token_refreshed":
@@ -491,16 +597,29 @@ var AuraChannel = class {
491
597
  const ackMsg = msg;
492
598
  const refId = ackMsg.ref_id ?? ackMsg.message_id;
493
599
  if (refId) {
494
- const resolve = this._pendingAcks.get(refId);
495
- if (resolve) {
600
+ const settle = this._pendingAcks.get(refId);
601
+ if (settle) {
496
602
  this._pendingAcks.delete(refId);
497
- resolve();
603
+ settle("ok");
498
604
  }
499
605
  }
500
606
  break;
501
607
  }
502
608
  }
503
609
  }
610
+ /**
611
+ * Solde TOUS les accusés en attente de ce canal avec un statut donné.
612
+ *
613
+ * Le protocole ne corrèle pas l'erreur serveur au `ref_id` de l'écriture refusée : quand
614
+ * le serveur déclare n'avoir aucun abonnement pour ce canal, AUCUN accusé en vol ne peut
615
+ * plus aboutir — les solder tous est donc exact, pas approximatif.
616
+ */
617
+ _settlePendingAcks(status) {
618
+ if (this._pendingAcks.size === 0) return;
619
+ const settlers = Array.from(this._pendingAcks.values());
620
+ this._pendingAcks.clear();
621
+ for (const settle of settlers) settle(status);
622
+ }
504
623
  /** Dispatch a RealtimeEvent to matching listeners. */
505
624
  _dispatchEvent(event) {
506
625
  if (!event) return;
@@ -513,51 +632,49 @@ var AuraChannel = class {
513
632
  if (eventType === "presence") {
514
633
  const payload = event.payload;
515
634
  const action = payload.action;
516
- const key = payload.user_id || payload.client_id;
635
+ const clientId = payload.client_id;
636
+ const locate = () => {
637
+ for (const [key, arr] of Object.entries(this._presenceState)) {
638
+ const index = arr.findIndex((p) => p.client_id === clientId);
639
+ if (index >= 0) return { key, index };
640
+ }
641
+ return null;
642
+ };
517
643
  if (action === "join") {
518
- if (!this._presenceState[key]) this._presenceState[key] = [];
519
- if (!this._presenceState[key].some((p) => p.client_id === payload.client_id)) {
520
- const presenceInfo = {
521
- client_id: payload.client_id,
644
+ const key = payload.user_id || clientId;
645
+ if (!locate()) {
646
+ if (!this._presenceState[key]) this._presenceState[key] = [];
647
+ this._presenceState[key].push({
648
+ client_id: clientId,
522
649
  user_id: payload.user_id ?? void 0,
523
650
  metadata: payload.metadata ?? payload.meta ?? {},
524
651
  joined_at: (/* @__PURE__ */ new Date()).toISOString(),
525
652
  last_seen: (/* @__PURE__ */ new Date()).toISOString()
526
- };
527
- this._presenceState[key].push(presenceInfo);
653
+ });
528
654
  }
529
655
  } else if (action === "leave") {
530
- const arr = this._presenceState[key];
531
- if (arr) {
532
- const idx = arr.findIndex((p) => p.client_id === payload.client_id);
533
- if (idx >= 0) arr.splice(idx, 1);
534
- if (arr.length === 0) delete this._presenceState[key];
656
+ const found = locate();
657
+ if (found) {
658
+ const arr = this._presenceState[found.key];
659
+ arr.splice(found.index, 1);
660
+ if (arr.length === 0) delete this._presenceState[found.key];
535
661
  }
536
662
  } else if (action === "update") {
537
- const arr = this._presenceState[key];
538
- if (arr) {
539
- const idx = arr.findIndex((p) => p.client_id === payload.client_id);
540
- if (idx >= 0) {
541
- arr[idx].metadata = payload.metadata ?? payload.meta ?? {};
542
- arr[idx].last_seen = (/* @__PURE__ */ new Date()).toISOString();
543
- } else {
544
- const presenceInfo = {
545
- client_id: payload.client_id,
546
- user_id: payload.user_id ?? void 0,
547
- metadata: payload.metadata ?? payload.meta ?? {},
548
- joined_at: (/* @__PURE__ */ new Date()).toISOString(),
549
- last_seen: (/* @__PURE__ */ new Date()).toISOString()
550
- };
551
- this._presenceState[key].push(presenceInfo);
552
- }
663
+ const found = locate();
664
+ if (found) {
665
+ const entry = this._presenceState[found.key][found.index];
666
+ entry.metadata = payload.metadata ?? payload.meta ?? {};
667
+ entry.last_seen = (/* @__PURE__ */ new Date()).toISOString();
553
668
  } else {
554
- this._presenceState[key] = [{
555
- client_id: payload.client_id,
669
+ const key = payload.user_id || clientId;
670
+ if (!this._presenceState[key]) this._presenceState[key] = [];
671
+ this._presenceState[key].push({
672
+ client_id: clientId,
556
673
  user_id: payload.user_id ?? void 0,
557
674
  metadata: payload.metadata ?? payload.meta ?? {},
558
675
  joined_at: (/* @__PURE__ */ new Date()).toISOString(),
559
676
  last_seen: (/* @__PURE__ */ new Date()).toISOString()
560
- }];
677
+ });
561
678
  }
562
679
  }
563
680
  }
@@ -568,7 +685,7 @@ var AuraChannel = class {
568
685
  const filterEvent = filter.event.toLowerCase();
569
686
  if (filterEvent === "*" || filterEvent === eventType) {
570
687
  const payload = event.payload;
571
- if (filter.schema && payload.schema !== filter.schema) continue;
688
+ if (filter.schema && !schemaMatches(filter.schema, payload.schema)) continue;
572
689
  if (filter.table && payload.table !== filter.table) continue;
573
690
  if (filter.filter) {
574
691
  const target = eventType === "delete" ? payload.old_record : payload.record;
@@ -578,14 +695,32 @@ var AuraChannel = class {
578
695
  }
579
696
  }
580
697
  }
581
- listener.callback(event);
698
+ const cdcPayload = payload && typeof payload === "object" ? payload : {};
699
+ if (cdcPayload.new === void 0 && cdcPayload.record !== void 0) {
700
+ cdcPayload.new = cdcPayload.record;
701
+ }
702
+ if (cdcPayload.old === void 0 && cdcPayload.old_record !== void 0) {
703
+ cdcPayload.old = cdcPayload.old_record;
704
+ }
705
+ if (cdcPayload.eventType === void 0 && cdcPayload.type !== void 0) {
706
+ cdcPayload.eventType = cdcPayload.type;
707
+ }
708
+ const enrichedEvent = Object.assign(Object.create(event), {
709
+ new: cdcPayload.record,
710
+ old: cdcPayload.old_record,
711
+ eventType: cdcPayload.type,
712
+ schema: cdcPayload.schema,
713
+ table: cdcPayload.table
714
+ });
715
+ listener.callback(enrichedEvent);
582
716
  }
583
717
  }
584
718
  } else if (listener.type === "broadcast") {
585
719
  if (eventType === "broadcast") {
586
720
  const filter = listener.filter;
587
- const payload = event.payload;
588
- if (!filter.event || payload.event === filter.event) {
721
+ const broadcastPayload = event.payload && typeof event.payload === "object" ? event.payload : void 0;
722
+ const broadcastEvent = broadcastPayload?.event;
723
+ if (!filter.event || filter.event === "*" || broadcastEvent === filter.event) {
589
724
  listener.callback(event);
590
725
  }
591
726
  }
@@ -621,6 +756,24 @@ var AuraChannel = class {
621
756
  }
622
757
  };
623
758
 
759
+ // src/errors.ts
760
+ var RealtimeConnectionError = class _RealtimeConnectionError extends Error {
761
+ /** Phase du cycle de vie où l'échec s'est produit. */
762
+ phase;
763
+ /** Code de fermeture WebSocket (RFC 6455 §7.4), si le runtime l'a fourni. */
764
+ code;
765
+ /** Raison de fermeture fournie par le serveur, si présente (souvent vide). */
766
+ reason;
767
+ constructor(phase, message, options) {
768
+ super(message, options?.cause !== void 0 ? { cause: options.cause } : void 0);
769
+ this.name = "RealtimeConnectionError";
770
+ this.phase = phase;
771
+ this.code = options?.code;
772
+ this.reason = options?.reason;
773
+ Object.setPrototypeOf(this, _RealtimeConnectionError.prototype);
774
+ }
775
+ };
776
+
624
777
  // src/HeartbeatManager.ts
625
778
  var HeartbeatManager = class _HeartbeatManager {
626
779
  _worker = null;
@@ -764,29 +917,17 @@ function getTokenExpiryMs(token) {
764
917
  if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
765
918
  return exp * 1e3;
766
919
  }
767
-
768
- // src/errors.ts
769
- var RealtimeConnectionError = class _RealtimeConnectionError extends Error {
770
- /** Phase du cycle de vie où l'échec s'est produit. */
771
- phase;
772
- /** Code de fermeture WebSocket (RFC 6455 §7.4), si le runtime l'a fourni. */
773
- code;
774
- /** Raison de fermeture fournie par le serveur, si présente (souvent vide). */
775
- reason;
776
- constructor(phase, message, options) {
777
- super(message, options?.cause !== void 0 ? { cause: options.cause } : void 0);
778
- this.name = "RealtimeConnectionError";
779
- this.phase = phase;
780
- this.code = options?.code;
781
- this.reason = options?.reason;
782
- Object.setPrototypeOf(this, _RealtimeConnectionError.prototype);
783
- }
784
- };
920
+ function getTokenProjectId(token) {
921
+ const payload = decodeJwtPayload(token);
922
+ const pid = payload?.project_id;
923
+ return typeof pid === "string" && pid !== "" ? pid : null;
924
+ }
785
925
 
786
926
  // src/RealtimeService.ts
787
927
  var HEARTBEAT_INTERVAL_MS = 25e3;
788
928
  var MAX_TIMEOUT_MS = 2147483647;
789
929
  var LIVENESS_GUARD_INTERVAL_MS = 3e4;
930
+ 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}:/;
790
931
  var LIVENESS_MAX_SILENCE_MS = 9e4;
791
932
  var RealtimeService = class _RealtimeService {
792
933
  client;
@@ -804,6 +945,8 @@ var RealtimeService = class _RealtimeService {
804
945
  _tokenRefreshTimer = null;
805
946
  /** Dernier access_token propagé au serveur — évite les pushs redondants (propagation différentielle). */
806
947
  _lastSentToken = null;
948
+ /** Token d'authentification explicite passé à setAuth(), prioritaire sur authStore. */
949
+ _customAuthToken = null;
807
950
  /** Listeners d'erreurs de transport (handshake refusé, coupure, connexion morte). */
808
951
  _errorListeners = /* @__PURE__ */ new Set();
809
952
  /**
@@ -833,6 +976,10 @@ var RealtimeService = class _RealtimeService {
833
976
  get connectionState() {
834
977
  return this._state;
835
978
  }
979
+ /** Whether the WebSocket is currently connected. */
980
+ get isConnected() {
981
+ return this._state === "connected";
982
+ }
836
983
  /** Listen for connection state changes. */
837
984
  onStateChange(callback) {
838
985
  this._stateListeners.add(callback);
@@ -895,6 +1042,21 @@ var RealtimeService = class _RealtimeService {
895
1042
  }
896
1043
  }
897
1044
  }
1045
+ // ─── Préfixe de projet des canaux ───────────────────────────────────────
1046
+ /**
1047
+ * Préfixe de projet déduit du token courant (`<project_id>:`), vide si aucun token.
1048
+ *
1049
+ * Utilisé pour normaliser les noms de canaux : le serveur (`aura-realtime`) attend
1050
+ * systématiquement `<project_id>:<canal>` sur le protocole WebSocket et REST, alors que
1051
+ * l'utilisateur passe généralement un nom court (`room-1`). Sans session, aucun préfixe
1052
+ * à préfixer ; la connexion WS échouerait de toute façon, le serveur exigeant un JWT.
1053
+ */
1054
+ _projectPrefix() {
1055
+ const token = this._customAuthToken ?? this.client.authStore.token;
1056
+ if (!token) return "";
1057
+ const pid = getTokenProjectId(token);
1058
+ return pid ? `${pid}:` : "";
1059
+ }
898
1060
  // ─── WebSocket URL ──────────────────────────────────────────────────────
899
1061
  get _wsUrl() {
900
1062
  const base = this.client.baseUrl.replace(/^http/, "ws");
@@ -908,7 +1070,7 @@ var RealtimeService = class _RealtimeService {
908
1070
  }
909
1071
  this._intentionalClose = false;
910
1072
  this._setState("connecting");
911
- const token = this.client.authStore.token;
1073
+ const token = this._customAuthToken ?? this.client.authStore.token;
912
1074
  const params = new URLSearchParams();
913
1075
  if (token) params.set("token", token);
914
1076
  if (this.client.apiKey) params.set("apikey", this.client.apiKey);
@@ -927,6 +1089,9 @@ var RealtimeService = class _RealtimeService {
927
1089
  opened = true;
928
1090
  this._reconnectAttempts = 0;
929
1091
  this._setState("connected");
1092
+ if (this._customAuthToken && this._customAuthToken !== this._lastSentToken) {
1093
+ this.refreshToken(this._customAuthToken);
1094
+ }
930
1095
  this._awaitingPong = false;
931
1096
  this._lastServerMessageAt = Date.now();
932
1097
  this._heartbeat.start(() => this._heartbeatTick(ws), HEARTBEAT_INTERVAL_MS);
@@ -1104,11 +1269,21 @@ var RealtimeService = class _RealtimeService {
1104
1269
  this._goDisconnected();
1105
1270
  }
1106
1271
  }
1107
- /** Send raw data through the WebSocket. */
1272
+ /**
1273
+ * Send raw data through the WebSocket.
1274
+ *
1275
+ * @returns `true` si le message a été remis à la socket, `false` s'il a été **jeté**
1276
+ * (socket absente ou non-OPEN). Ce retour n'est pas cosmétique : sans lui,
1277
+ * `AuraChannel.send()`/`track()` rendaient `'ok'` pour un message qui n'avait jamais
1278
+ * quitté le process — la seule branche de `send()` capable de rendre `'error'` était
1279
+ * une exception que ce `_send` ne lève jamais (C18/N24).
1280
+ */
1108
1281
  _send(data) {
1109
1282
  if (this._ws && this._ws.readyState === WebSocket.OPEN) {
1110
1283
  this._ws.send(data);
1284
+ return true;
1111
1285
  }
1286
+ return false;
1112
1287
  }
1113
1288
  /** Refresh the JWT token on an existing connection (no disconnect). */
1114
1289
  refreshToken(token) {
@@ -1142,36 +1317,83 @@ var RealtimeService = class _RealtimeService {
1142
1317
  */
1143
1318
  setAuth(token) {
1144
1319
  const resolved = token ?? this.client.authStore.token;
1145
- if (resolved == null) return;
1320
+ this._customAuthToken = resolved ?? null;
1321
+ if (resolved == null) {
1322
+ this._lastSentToken = null;
1323
+ return;
1324
+ }
1146
1325
  this.refreshToken(resolved);
1147
1326
  }
1148
1327
  /**
1149
1328
  * Envoie un broadcast par HTTP (REST), sans connexion WebSocket ouverte
1150
1329
  * (parité `@supabase/realtime-js`). Utile côté serveur/edge.
1151
1330
  *
1152
- * POST `/v1/realtime/{projectId}/broadcast` avec le contrat du handler backend
1331
+ * POST `/v1/realtime/broadcast` avec le contrat du handler backend
1153
1332
  * (`broadcast.rs`) : `{ channel, payload }`, `channel` préfixé par le projet,
1154
1333
  * `event` replié dans `payload` (aligné sur le broadcast WS).
1155
1334
  *
1156
- * @param channel Nom du canal (préfixé projet automatiquement si absent).
1335
+ * ### Le canal DOIT porter le projet — y compris hors session (K2)
1336
+ *
1337
+ * Le handler exige `channel.starts_with("{project_id}:")` et répond sinon
1338
+ * `403 « Le canal n'appartient pas à ce projet »`
1339
+ * (`services/aura-realtime/src/handlers/broadcast.rs`, fonction `broadcast`).
1340
+ *
1341
+ * Avec une session, ce préfixe est ajouté ici même : il est lu dans le claim
1342
+ * `project_id` du jeton (voir {@link _projectPrefix}).
1343
+ *
1344
+ * **Sans session — le cas d'usage serveur/edge de cette méthode** — le SDK n'a AUCUN
1345
+ * identifiant de projet : l'URL est la seule désignation du tenant côté client, et rien
1346
+ * ne la traduit en `project_id` (c'est la passerelle qui fait cette résolution, à partir
1347
+ * de l'hôte). L'authentification, elle, passe : pour le realtime, la passerelle forge un
1348
+ * JWT interne à partir des claims de la clé API et écrase l'`Authorization`
1349
+ * (`aura-gateway/src/proxy/sse_proxy.rs::do_http_proxy`). Émettre un canal nu produisait
1350
+ * donc un `403` dont le message ne nomme jamais la forme attendue.
1351
+ *
1352
+ * Cette méthode **refuse** désormais ce cas, sans appel réseau
1353
+ * (`error.code === 'realtime_channel_non_qualifie'`) : passez le canal déjà qualifié,
1354
+ * `"<project_id>:<canal>"`. Cette forme est acceptée dans les deux situations — avec une
1355
+ * session, elle n'est pas préfixée une seconde fois.
1356
+ *
1357
+ * @param channel Nom du canal. Avec une session : nom nu ou déjà qualifié. Sans session :
1358
+ * OBLIGATOIREMENT `"<project_id>:<canal>"`.
1157
1359
  * @param event Nom de l'événement broadcast.
1158
1360
  * @param payload Données de l'événement.
1159
- * @returns `AuraResponse<null>` — `error` non nul en cas de 4xx/rate-limit.
1361
+ * @returns `AuraResponse<null>` — `error` non nul en cas de 4xx/rate-limit, ou de canal
1362
+ * non qualifiable (refus local, aucun appel réseau).
1160
1363
  *
1161
1364
  * @example
1162
1365
  * ```ts
1366
+ * // Navigateur, utilisateur connecté : le projet vient du jeton de session.
1163
1367
  * await aura.realtime.httpBroadcast('room-1', 'cursor', { x: 10, y: 20 })
1368
+ *
1369
+ * // Serveur / edge (clé service_role, aucune session) : qualifier le canal.
1370
+ * await aura.realtime.httpBroadcast(`${PROJECT_ID}:room-1`, 'cursor', { x: 10, y: 20 })
1164
1371
  * ```
1165
1372
  */
1166
1373
  httpBroadcast(channel, event, payload = {}) {
1167
- const prefix = `${this.client.projectId}:`;
1168
- const fullChannel = channel.startsWith(prefix) ? channel : `${prefix}${channel}`;
1374
+ const prefix = this._projectPrefix();
1375
+ let fullChannel;
1376
+ if (prefix) {
1377
+ fullChannel = channel.startsWith(prefix) ? channel : `${prefix}${channel}`;
1378
+ } else if (CANAL_QUALIFIE.test(channel)) {
1379
+ fullChannel = channel;
1380
+ } else {
1381
+ return Promise.resolve({
1382
+ data: null,
1383
+ error: new AuraError(0, {
1384
+ error: {
1385
+ code: "realtime_channel_non_qualifie",
1386
+ 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.`
1387
+ }
1388
+ })
1389
+ });
1390
+ }
1169
1391
  const body = {
1170
1392
  channel: fullChannel,
1171
1393
  payload: { event, ...payload }
1172
1394
  };
1173
1395
  return fetchWithAuth(
1174
- `${this.client.baseUrl}/v1/realtime/${this.client.projectId}/broadcast`,
1396
+ `${this.client.baseUrl}/v1/realtime/broadcast`,
1175
1397
  { method: "POST", body },
1176
1398
  this.client
1177
1399
  );
@@ -1269,7 +1491,7 @@ var RealtimeService = class _RealtimeService {
1269
1491
  channel(name, options) {
1270
1492
  const existing = this._channels.get(name);
1271
1493
  if (existing) return existing;
1272
- const ch = new AuraChannel(name, this.client.projectId, options);
1494
+ const ch = new AuraChannel(name, () => this._projectPrefix(), options);
1273
1495
  this._channels.set(name, ch);
1274
1496
  this.connect();
1275
1497
  if (this._ws && this._ws.readyState === WebSocket.OPEN) {
@@ -1277,8 +1499,9 @@ var RealtimeService = class _RealtimeService {
1277
1499
  }
1278
1500
  return ch;
1279
1501
  }
1280
- /** Remove a channel. */
1281
- removeChannel(name) {
1502
+ /** Remove a channel by name or by AuraChannel instance. */
1503
+ removeChannel(channel) {
1504
+ const name = typeof channel === "string" ? channel : channel.name;
1282
1505
  const ch = this._channels.get(name);
1283
1506
  if (ch) {
1284
1507
  ch.unsubscribe();
@@ -1301,19 +1524,37 @@ var RealtimeService = class _RealtimeService {
1301
1524
  _dispatch(msg) {
1302
1525
  const channelFull = "channel" in msg ? msg.channel : "topic" in msg ? msg.topic : void 0;
1303
1526
  if (channelFull) {
1304
- const prefix = `${this.client.projectId}:`;
1305
- const name = channelFull.startsWith(prefix) ? channelFull.slice(prefix.length) : channelFull;
1527
+ const prefix = this._projectPrefix();
1528
+ const name = prefix && channelFull.startsWith(prefix) ? channelFull.slice(prefix.length) : channelFull;
1306
1529
  const ch = this._channels.get(name);
1307
1530
  if (ch) {
1308
1531
  ch._onMessage(msg);
1309
1532
  return;
1310
1533
  }
1534
+ if (channelFull.includes(":")) {
1535
+ const colonIdx = channelFull.indexOf(":");
1536
+ const relativeName = channelFull.slice(colonIdx + 1);
1537
+ const relCh = this._channels.get(relativeName);
1538
+ if (relCh) {
1539
+ relCh._onMessage(msg);
1540
+ return;
1541
+ }
1542
+ }
1311
1543
  for (const [, channel] of this._channels) {
1312
- if (channel.fullName === channelFull || channelFull.startsWith(channel.fullName + ":")) {
1544
+ if (channel.fullName === channelFull || channel.name === channelFull || channelFull.startsWith(channel.fullName + ":") || channelFull.endsWith(`:${channel.name}`)) {
1313
1545
  channel._onMessage(msg);
1314
1546
  return;
1315
1547
  }
1316
1548
  }
1549
+ if (msg.type === "error") {
1550
+ if (typeof console !== "undefined") {
1551
+ console.warn(
1552
+ "[aura-realtime] Erreur serveur pour un canal inconnu de ce client \u2014 ignor\xE9e (aucun canal local concern\xE9) :",
1553
+ { channel: channelFull, message: msg.message }
1554
+ );
1555
+ }
1556
+ return;
1557
+ }
1317
1558
  }
1318
1559
  if (msg.type === "token_refreshed") {
1319
1560
  return;
@@ -1347,22 +1588,6 @@ var RealtimeService = class _RealtimeService {
1347
1588
  }
1348
1589
  }
1349
1590
  }
1350
- // ─── Backward compatibility ─────────────────────────────────────────────
1351
- /**
1352
- * Simple callback-based subscription (legacy API).
1353
- * Prefer using `.channel().on().subscribe()` instead.
1354
- *
1355
- * @deprecated Use `channel().on().subscribe()` instead.
1356
- */
1357
- on(channelName, callback) {
1358
- this.connect();
1359
- const ch = this.channel(channelName);
1360
- ch.on("postgres_changes", { event: "*" }, callback);
1361
- ch.subscribe();
1362
- return {
1363
- unsubscribe: () => this.removeChannel(channelName)
1364
- };
1365
- }
1366
1591
  };
1367
1592
 
1368
1593
  export { AuraChannel, DEFAULT_SUBSCRIBE_TIMEOUT_MS, HEARTBEAT_INTERVAL_MS, HeartbeatManager, RealtimeConnectionError, RealtimeService };