@aurabase/realtime 0.4.1 → 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,6 +341,7 @@ 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
  }
@@ -365,45 +412,84 @@ var AuraChannel = class {
365
412
  }
366
413
  // ─── Broadcast ──────────────────────────────────────────────────────────
367
414
  /**
368
- * Send a broadcast message to the channel.
369
- * 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`).
370
427
  *
371
428
  * @example
372
429
  * ```ts
373
- * // Without ACK (fire & forget)
430
+ * // Sans ACK (fire & forget)
374
431
  * channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100, y: 200 } })
375
432
  *
376
- * // With ACK (wait for server confirmation)
377
- * 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
+ * })
378
442
  * ```
379
443
  */
380
444
  send(message) {
381
445
  return new Promise((resolve) => {
382
446
  if (!this._sendFn) {
383
- resolve("error");
447
+ resolve("not_connected");
448
+ return;
449
+ }
450
+ if (!this._subscribed || this._serverState === "refused") {
451
+ resolve("requires_subscription");
384
452
  return;
385
453
  }
386
454
  const ref_id = message.ref_id ?? this._nextRef();
387
- if (this._broadcastAck) {
388
- const timer = setTimeout(() => {
455
+ const ackExpected = this._broadcastAck;
456
+ let ackTimer = null;
457
+ if (ackExpected) {
458
+ ackTimer = setTimeout(() => {
389
459
  this._pendingAcks.delete(ref_id);
390
460
  resolve("timeout");
391
461
  }, 5e3);
392
- this._pendingAcks.set(ref_id, () => {
393
- clearTimeout(timer);
394
- resolve("ok");
462
+ this._pendingAcks.set(ref_id, (status) => {
463
+ if (ackTimer) clearTimeout(ackTimer);
464
+ resolve(status);
395
465
  });
396
- } else {
397
- resolve("ok");
398
466
  }
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
- );
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");
407
493
  });
408
494
  }
409
495
  // ─── Presence ───────────────────────────────────────────────────────────
@@ -416,19 +502,25 @@ var AuraChannel = class {
416
502
  * await channel.track({ user: 'user-1', online_at: new Date().toISOString() })
417
503
  * ```
418
504
  */
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";
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");
429
521
  }
430
- /** Stop tracking presence. */
431
- async untrack() {
522
+ /** Stop tracking presence. Même contrat que `track()`. */
523
+ untrack() {
432
524
  return this.track({});
433
525
  }
434
526
  /** Get the current presence state for this channel. */
@@ -442,6 +534,7 @@ var AuraChannel = class {
442
534
  case "subscribed": {
443
535
  const subMsg = msg;
444
536
  if (subMsg.subscription_id) this._subscriptionId = subMsg.subscription_id;
537
+ this._serverState = "subscribed";
445
538
  this._clearSubscribeTimeout();
446
539
  this._emitStatus("SUBSCRIBED");
447
540
  break;
@@ -481,8 +574,18 @@ var AuraChannel = class {
481
574
  }
482
575
  case "error": {
483
576
  const errMsg = msg;
577
+ const errChannel = errMsg.channel;
578
+ if (errChannel && errChannel !== this.fullName && errChannel !== this.name) {
579
+ break;
580
+ }
484
581
  this._clearSubscribeTimeout();
485
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
+ }
486
589
  break;
487
590
  }
488
591
  case "token_refreshed":
@@ -491,16 +594,29 @@ var AuraChannel = class {
491
594
  const ackMsg = msg;
492
595
  const refId = ackMsg.ref_id ?? ackMsg.message_id;
493
596
  if (refId) {
494
- const resolve = this._pendingAcks.get(refId);
495
- if (resolve) {
597
+ const settle = this._pendingAcks.get(refId);
598
+ if (settle) {
496
599
  this._pendingAcks.delete(refId);
497
- resolve();
600
+ settle("ok");
498
601
  }
499
602
  }
500
603
  break;
501
604
  }
502
605
  }
503
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
+ }
504
620
  /** Dispatch a RealtimeEvent to matching listeners. */
505
621
  _dispatchEvent(event) {
506
622
  if (!event) return;
@@ -513,51 +629,49 @@ var AuraChannel = class {
513
629
  if (eventType === "presence") {
514
630
  const payload = event.payload;
515
631
  const action = payload.action;
516
- 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
+ };
517
640
  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,
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,
522
646
  user_id: payload.user_id ?? void 0,
523
647
  metadata: payload.metadata ?? payload.meta ?? {},
524
648
  joined_at: (/* @__PURE__ */ new Date()).toISOString(),
525
649
  last_seen: (/* @__PURE__ */ new Date()).toISOString()
526
- };
527
- this._presenceState[key].push(presenceInfo);
650
+ });
528
651
  }
529
652
  } 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];
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];
535
658
  }
536
659
  } 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
- }
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();
553
665
  } else {
554
- this._presenceState[key] = [{
555
- 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,
556
670
  user_id: payload.user_id ?? void 0,
557
671
  metadata: payload.metadata ?? payload.meta ?? {},
558
672
  joined_at: (/* @__PURE__ */ new Date()).toISOString(),
559
673
  last_seen: (/* @__PURE__ */ new Date()).toISOString()
560
- }];
674
+ });
561
675
  }
562
676
  }
563
677
  }
@@ -568,7 +682,7 @@ var AuraChannel = class {
568
682
  const filterEvent = filter.event.toLowerCase();
569
683
  if (filterEvent === "*" || filterEvent === eventType) {
570
684
  const payload = event.payload;
571
- if (filter.schema && payload.schema !== filter.schema) continue;
685
+ if (filter.schema && !schemaMatches(filter.schema, payload.schema)) continue;
572
686
  if (filter.table && payload.table !== filter.table) continue;
573
687
  if (filter.filter) {
574
688
  const target = eventType === "delete" ? payload.old_record : payload.record;
@@ -621,6 +735,24 @@ var AuraChannel = class {
621
735
  }
622
736
  };
623
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
+
624
756
  // src/HeartbeatManager.ts
625
757
  var HeartbeatManager = class _HeartbeatManager {
626
758
  _worker = null;
@@ -764,29 +896,17 @@ function getTokenExpiryMs(token) {
764
896
  if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
765
897
  return exp * 1e3;
766
898
  }
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
- };
899
+ function getTokenProjectId(token) {
900
+ const payload = decodeJwtPayload(token);
901
+ const pid = payload?.project_id;
902
+ return typeof pid === "string" && pid !== "" ? pid : null;
903
+ }
785
904
 
786
905
  // src/RealtimeService.ts
787
906
  var HEARTBEAT_INTERVAL_MS = 25e3;
788
907
  var MAX_TIMEOUT_MS = 2147483647;
789
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}:/;
790
910
  var LIVENESS_MAX_SILENCE_MS = 9e4;
791
911
  var RealtimeService = class _RealtimeService {
792
912
  client;
@@ -895,6 +1015,23 @@ var RealtimeService = class _RealtimeService {
895
1015
  }
896
1016
  }
897
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
+ }
898
1035
  // ─── WebSocket URL ──────────────────────────────────────────────────────
899
1036
  get _wsUrl() {
900
1037
  const base = this.client.baseUrl.replace(/^http/, "ws");
@@ -1104,11 +1241,21 @@ var RealtimeService = class _RealtimeService {
1104
1241
  this._goDisconnected();
1105
1242
  }
1106
1243
  }
1107
- /** 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
+ */
1108
1253
  _send(data) {
1109
1254
  if (this._ws && this._ws.readyState === WebSocket.OPEN) {
1110
1255
  this._ws.send(data);
1256
+ return true;
1111
1257
  }
1258
+ return false;
1112
1259
  }
1113
1260
  /** Refresh the JWT token on an existing connection (no disconnect). */
1114
1261
  refreshToken(token) {
@@ -1149,29 +1296,72 @@ var RealtimeService = class _RealtimeService {
1149
1296
  * Envoie un broadcast par HTTP (REST), sans connexion WebSocket ouverte
1150
1297
  * (parité `@supabase/realtime-js`). Utile côté serveur/edge.
1151
1298
  *
1152
- * POST `/v1/realtime/{projectId}/broadcast` avec le contrat du handler backend
1299
+ * POST `/v1/realtime/broadcast` avec le contrat du handler backend
1153
1300
  * (`broadcast.rs`) : `{ channel, payload }`, `channel` préfixé par le projet,
1154
1301
  * `event` replié dans `payload` (aligné sur le broadcast WS).
1155
1302
  *
1156
- * @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>"`.
1157
1327
  * @param event Nom de l'événement broadcast.
1158
1328
  * @param payload Données de l'événement.
1159
- * @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).
1160
1331
  *
1161
1332
  * @example
1162
1333
  * ```ts
1334
+ * // Navigateur, utilisateur connecté : le projet vient du jeton de session.
1163
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 })
1164
1339
  * ```
1165
1340
  */
1166
1341
  httpBroadcast(channel, event, payload = {}) {
1167
- const prefix = `${this.client.projectId}:`;
1168
- 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
+ }
1169
1359
  const body = {
1170
1360
  channel: fullChannel,
1171
1361
  payload: { event, ...payload }
1172
1362
  };
1173
1363
  return fetchWithAuth(
1174
- `${this.client.baseUrl}/v1/realtime/${this.client.projectId}/broadcast`,
1364
+ `${this.client.baseUrl}/v1/realtime/broadcast`,
1175
1365
  { method: "POST", body },
1176
1366
  this.client
1177
1367
  );
@@ -1269,7 +1459,7 @@ var RealtimeService = class _RealtimeService {
1269
1459
  channel(name, options) {
1270
1460
  const existing = this._channels.get(name);
1271
1461
  if (existing) return existing;
1272
- const ch = new AuraChannel(name, this.client.projectId, options);
1462
+ const ch = new AuraChannel(name, () => this._projectPrefix(), options);
1273
1463
  this._channels.set(name, ch);
1274
1464
  this.connect();
1275
1465
  if (this._ws && this._ws.readyState === WebSocket.OPEN) {
@@ -1301,8 +1491,8 @@ var RealtimeService = class _RealtimeService {
1301
1491
  _dispatch(msg) {
1302
1492
  const channelFull = "channel" in msg ? msg.channel : "topic" in msg ? msg.topic : void 0;
1303
1493
  if (channelFull) {
1304
- const prefix = `${this.client.projectId}:`;
1305
- 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;
1306
1496
  const ch = this._channels.get(name);
1307
1497
  if (ch) {
1308
1498
  ch._onMessage(msg);
@@ -1314,6 +1504,15 @@ var RealtimeService = class _RealtimeService {
1314
1504
  return;
1315
1505
  }
1316
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
+ }
1317
1516
  }
1318
1517
  if (msg.type === "token_refreshed") {
1319
1518
  return;
@@ -1347,22 +1546,6 @@ var RealtimeService = class _RealtimeService {
1347
1546
  }
1348
1547
  }
1349
1548
  }
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
1549
  };
1367
1550
 
1368
1551
  export { AuraChannel, DEFAULT_SUBSCRIBE_TIMEOUT_MS, HEARTBEAT_INTERVAL_MS, HeartbeatManager, RealtimeConnectionError, RealtimeService };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurabase/realtime",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Service temps réel du SDK Aurabase (postgres_changes, broadcast, presence)",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -45,7 +45,7 @@
45
45
  "realtime"
46
46
  ],
47
47
  "dependencies": {
48
- "@aurabase/core": "0.4.1"
48
+ "@aurabase/core": "0.5.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "20.14.15",