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