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