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