@aurabase/realtime 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -4
- package/dist/index.cjs +302 -119
- package/dist/index.d.cts +182 -94
- package/dist/index.d.ts +182 -94
- package/dist/index.js +303 -120
- package/package.json +2 -2
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,6 +343,7 @@ 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
|
}
|
|
@@ -367,45 +414,84 @@ var AuraChannel = class {
|
|
|
367
414
|
}
|
|
368
415
|
// ─── Broadcast ──────────────────────────────────────────────────────────
|
|
369
416
|
/**
|
|
370
|
-
*
|
|
371
|
-
*
|
|
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`).
|
|
372
429
|
*
|
|
373
430
|
* @example
|
|
374
431
|
* ```ts
|
|
375
|
-
* //
|
|
432
|
+
* // Sans ACK (fire & forget)
|
|
376
433
|
* channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100, y: 200 } })
|
|
377
434
|
*
|
|
378
|
-
* //
|
|
379
|
-
*
|
|
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
|
+
* })
|
|
380
444
|
* ```
|
|
381
445
|
*/
|
|
382
446
|
send(message) {
|
|
383
447
|
return new Promise((resolve) => {
|
|
384
448
|
if (!this._sendFn) {
|
|
385
|
-
resolve("
|
|
449
|
+
resolve("not_connected");
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (!this._subscribed || this._serverState === "refused") {
|
|
453
|
+
resolve("requires_subscription");
|
|
386
454
|
return;
|
|
387
455
|
}
|
|
388
456
|
const ref_id = message.ref_id ?? this._nextRef();
|
|
389
|
-
|
|
390
|
-
|
|
457
|
+
const ackExpected = this._broadcastAck;
|
|
458
|
+
let ackTimer = null;
|
|
459
|
+
if (ackExpected) {
|
|
460
|
+
ackTimer = setTimeout(() => {
|
|
391
461
|
this._pendingAcks.delete(ref_id);
|
|
392
462
|
resolve("timeout");
|
|
393
463
|
}, 5e3);
|
|
394
|
-
this._pendingAcks.set(ref_id, () => {
|
|
395
|
-
clearTimeout(
|
|
396
|
-
resolve(
|
|
464
|
+
this._pendingAcks.set(ref_id, (status) => {
|
|
465
|
+
if (ackTimer) clearTimeout(ackTimer);
|
|
466
|
+
resolve(status);
|
|
397
467
|
});
|
|
398
|
-
} else {
|
|
399
|
-
resolve("ok");
|
|
400
468
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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");
|
|
409
495
|
});
|
|
410
496
|
}
|
|
411
497
|
// ─── Presence ───────────────────────────────────────────────────────────
|
|
@@ -418,19 +504,25 @@ var AuraChannel = class {
|
|
|
418
504
|
* await channel.track({ user: 'user-1', online_at: new Date().toISOString() })
|
|
419
505
|
* ```
|
|
420
506
|
*/
|
|
421
|
-
|
|
422
|
-
if (!this._sendFn) return "error";
|
|
423
|
-
this.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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");
|
|
431
523
|
}
|
|
432
|
-
/** Stop tracking presence. */
|
|
433
|
-
|
|
524
|
+
/** Stop tracking presence. Même contrat que `track()`. */
|
|
525
|
+
untrack() {
|
|
434
526
|
return this.track({});
|
|
435
527
|
}
|
|
436
528
|
/** Get the current presence state for this channel. */
|
|
@@ -444,6 +536,7 @@ var AuraChannel = class {
|
|
|
444
536
|
case "subscribed": {
|
|
445
537
|
const subMsg = msg;
|
|
446
538
|
if (subMsg.subscription_id) this._subscriptionId = subMsg.subscription_id;
|
|
539
|
+
this._serverState = "subscribed";
|
|
447
540
|
this._clearSubscribeTimeout();
|
|
448
541
|
this._emitStatus("SUBSCRIBED");
|
|
449
542
|
break;
|
|
@@ -483,8 +576,18 @@ var AuraChannel = class {
|
|
|
483
576
|
}
|
|
484
577
|
case "error": {
|
|
485
578
|
const errMsg = msg;
|
|
579
|
+
const errChannel = errMsg.channel;
|
|
580
|
+
if (errChannel && errChannel !== this.fullName && errChannel !== this.name) {
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
486
583
|
this._clearSubscribeTimeout();
|
|
487
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
|
+
}
|
|
488
591
|
break;
|
|
489
592
|
}
|
|
490
593
|
case "token_refreshed":
|
|
@@ -493,16 +596,29 @@ var AuraChannel = class {
|
|
|
493
596
|
const ackMsg = msg;
|
|
494
597
|
const refId = ackMsg.ref_id ?? ackMsg.message_id;
|
|
495
598
|
if (refId) {
|
|
496
|
-
const
|
|
497
|
-
if (
|
|
599
|
+
const settle = this._pendingAcks.get(refId);
|
|
600
|
+
if (settle) {
|
|
498
601
|
this._pendingAcks.delete(refId);
|
|
499
|
-
|
|
602
|
+
settle("ok");
|
|
500
603
|
}
|
|
501
604
|
}
|
|
502
605
|
break;
|
|
503
606
|
}
|
|
504
607
|
}
|
|
505
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
|
+
}
|
|
506
622
|
/** Dispatch a RealtimeEvent to matching listeners. */
|
|
507
623
|
_dispatchEvent(event) {
|
|
508
624
|
if (!event) return;
|
|
@@ -515,51 +631,49 @@ var AuraChannel = class {
|
|
|
515
631
|
if (eventType === "presence") {
|
|
516
632
|
const payload = event.payload;
|
|
517
633
|
const action = payload.action;
|
|
518
|
-
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
|
+
};
|
|
519
642
|
if (action === "join") {
|
|
520
|
-
|
|
521
|
-
if (!
|
|
522
|
-
|
|
523
|
-
|
|
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,
|
|
524
648
|
user_id: payload.user_id ?? void 0,
|
|
525
649
|
metadata: payload.metadata ?? payload.meta ?? {},
|
|
526
650
|
joined_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
527
651
|
last_seen: (/* @__PURE__ */ new Date()).toISOString()
|
|
528
|
-
};
|
|
529
|
-
this._presenceState[key].push(presenceInfo);
|
|
652
|
+
});
|
|
530
653
|
}
|
|
531
654
|
} else if (action === "leave") {
|
|
532
|
-
const
|
|
533
|
-
if (
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
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];
|
|
537
660
|
}
|
|
538
661
|
} 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
|
-
}
|
|
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();
|
|
555
667
|
} else {
|
|
556
|
-
|
|
557
|
-
|
|
668
|
+
const key = payload.user_id || clientId;
|
|
669
|
+
if (!this._presenceState[key]) this._presenceState[key] = [];
|
|
670
|
+
this._presenceState[key].push({
|
|
671
|
+
client_id: clientId,
|
|
558
672
|
user_id: payload.user_id ?? void 0,
|
|
559
673
|
metadata: payload.metadata ?? payload.meta ?? {},
|
|
560
674
|
joined_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
561
675
|
last_seen: (/* @__PURE__ */ new Date()).toISOString()
|
|
562
|
-
}
|
|
676
|
+
});
|
|
563
677
|
}
|
|
564
678
|
}
|
|
565
679
|
}
|
|
@@ -570,7 +684,7 @@ var AuraChannel = class {
|
|
|
570
684
|
const filterEvent = filter.event.toLowerCase();
|
|
571
685
|
if (filterEvent === "*" || filterEvent === eventType) {
|
|
572
686
|
const payload = event.payload;
|
|
573
|
-
if (filter.schema &&
|
|
687
|
+
if (filter.schema && !schemaMatches(filter.schema, payload.schema)) continue;
|
|
574
688
|
if (filter.table && payload.table !== filter.table) continue;
|
|
575
689
|
if (filter.filter) {
|
|
576
690
|
const target = eventType === "delete" ? payload.old_record : payload.record;
|
|
@@ -623,6 +737,24 @@ var AuraChannel = class {
|
|
|
623
737
|
}
|
|
624
738
|
};
|
|
625
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
|
+
|
|
626
758
|
// src/HeartbeatManager.ts
|
|
627
759
|
var HeartbeatManager = class _HeartbeatManager {
|
|
628
760
|
_worker = null;
|
|
@@ -766,29 +898,17 @@ function getTokenExpiryMs(token) {
|
|
|
766
898
|
if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
|
|
767
899
|
return exp * 1e3;
|
|
768
900
|
}
|
|
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
|
-
};
|
|
901
|
+
function getTokenProjectId(token) {
|
|
902
|
+
const payload = decodeJwtPayload(token);
|
|
903
|
+
const pid = payload?.project_id;
|
|
904
|
+
return typeof pid === "string" && pid !== "" ? pid : null;
|
|
905
|
+
}
|
|
787
906
|
|
|
788
907
|
// src/RealtimeService.ts
|
|
789
908
|
var HEARTBEAT_INTERVAL_MS = 25e3;
|
|
790
909
|
var MAX_TIMEOUT_MS = 2147483647;
|
|
791
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}:/;
|
|
792
912
|
var LIVENESS_MAX_SILENCE_MS = 9e4;
|
|
793
913
|
var RealtimeService = class _RealtimeService {
|
|
794
914
|
client;
|
|
@@ -897,6 +1017,23 @@ var RealtimeService = class _RealtimeService {
|
|
|
897
1017
|
}
|
|
898
1018
|
}
|
|
899
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
|
+
}
|
|
900
1037
|
// ─── WebSocket URL ──────────────────────────────────────────────────────
|
|
901
1038
|
get _wsUrl() {
|
|
902
1039
|
const base = this.client.baseUrl.replace(/^http/, "ws");
|
|
@@ -1106,11 +1243,21 @@ var RealtimeService = class _RealtimeService {
|
|
|
1106
1243
|
this._goDisconnected();
|
|
1107
1244
|
}
|
|
1108
1245
|
}
|
|
1109
|
-
/**
|
|
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
|
+
*/
|
|
1110
1255
|
_send(data) {
|
|
1111
1256
|
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
1112
1257
|
this._ws.send(data);
|
|
1258
|
+
return true;
|
|
1113
1259
|
}
|
|
1260
|
+
return false;
|
|
1114
1261
|
}
|
|
1115
1262
|
/** Refresh the JWT token on an existing connection (no disconnect). */
|
|
1116
1263
|
refreshToken(token) {
|
|
@@ -1151,29 +1298,72 @@ var RealtimeService = class _RealtimeService {
|
|
|
1151
1298
|
* Envoie un broadcast par HTTP (REST), sans connexion WebSocket ouverte
|
|
1152
1299
|
* (parité `@supabase/realtime-js`). Utile côté serveur/edge.
|
|
1153
1300
|
*
|
|
1154
|
-
* POST `/v1/realtime/
|
|
1301
|
+
* POST `/v1/realtime/broadcast` avec le contrat du handler backend
|
|
1155
1302
|
* (`broadcast.rs`) : `{ channel, payload }`, `channel` préfixé par le projet,
|
|
1156
1303
|
* `event` replié dans `payload` (aligné sur le broadcast WS).
|
|
1157
1304
|
*
|
|
1158
|
-
*
|
|
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>"`.
|
|
1159
1329
|
* @param event Nom de l'événement broadcast.
|
|
1160
1330
|
* @param payload Données de l'événement.
|
|
1161
|
-
* @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).
|
|
1162
1333
|
*
|
|
1163
1334
|
* @example
|
|
1164
1335
|
* ```ts
|
|
1336
|
+
* // Navigateur, utilisateur connecté : le projet vient du jeton de session.
|
|
1165
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 })
|
|
1166
1341
|
* ```
|
|
1167
1342
|
*/
|
|
1168
1343
|
httpBroadcast(channel, event, payload = {}) {
|
|
1169
|
-
const prefix =
|
|
1170
|
-
|
|
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
|
+
}
|
|
1171
1361
|
const body = {
|
|
1172
1362
|
channel: fullChannel,
|
|
1173
1363
|
payload: { event, ...payload }
|
|
1174
1364
|
};
|
|
1175
1365
|
return core.fetchWithAuth(
|
|
1176
|
-
`${this.client.baseUrl}/v1/realtime
|
|
1366
|
+
`${this.client.baseUrl}/v1/realtime/broadcast`,
|
|
1177
1367
|
{ method: "POST", body },
|
|
1178
1368
|
this.client
|
|
1179
1369
|
);
|
|
@@ -1271,7 +1461,7 @@ var RealtimeService = class _RealtimeService {
|
|
|
1271
1461
|
channel(name, options) {
|
|
1272
1462
|
const existing = this._channels.get(name);
|
|
1273
1463
|
if (existing) return existing;
|
|
1274
|
-
const ch = new AuraChannel(name, this.
|
|
1464
|
+
const ch = new AuraChannel(name, () => this._projectPrefix(), options);
|
|
1275
1465
|
this._channels.set(name, ch);
|
|
1276
1466
|
this.connect();
|
|
1277
1467
|
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
@@ -1303,8 +1493,8 @@ var RealtimeService = class _RealtimeService {
|
|
|
1303
1493
|
_dispatch(msg) {
|
|
1304
1494
|
const channelFull = "channel" in msg ? msg.channel : "topic" in msg ? msg.topic : void 0;
|
|
1305
1495
|
if (channelFull) {
|
|
1306
|
-
const prefix =
|
|
1307
|
-
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;
|
|
1308
1498
|
const ch = this._channels.get(name);
|
|
1309
1499
|
if (ch) {
|
|
1310
1500
|
ch._onMessage(msg);
|
|
@@ -1316,6 +1506,15 @@ var RealtimeService = class _RealtimeService {
|
|
|
1316
1506
|
return;
|
|
1317
1507
|
}
|
|
1318
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
|
+
}
|
|
1319
1518
|
}
|
|
1320
1519
|
if (msg.type === "token_refreshed") {
|
|
1321
1520
|
return;
|
|
@@ -1349,22 +1548,6 @@ var RealtimeService = class _RealtimeService {
|
|
|
1349
1548
|
}
|
|
1350
1549
|
}
|
|
1351
1550
|
}
|
|
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
1551
|
};
|
|
1369
1552
|
|
|
1370
1553
|
exports.AuraChannel = AuraChannel;
|