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