@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.js
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
|
-
import { fetchWithAuth } from '@aurabase/core';
|
|
2
|
-
|
|
3
|
-
// src/RealtimeService.ts
|
|
1
|
+
import { AuraError, fetchWithAuth } from '@aurabase/core';
|
|
4
2
|
|
|
5
3
|
// src/columnFilter.ts
|
|
4
|
+
function logicalSchema(physical) {
|
|
5
|
+
if (!physical.startsWith("project_")) return physical;
|
|
6
|
+
const rest = physical.slice("project_".length);
|
|
7
|
+
if (rest.length < 36) return physical;
|
|
8
|
+
switch (rest.slice(36)) {
|
|
9
|
+
case "":
|
|
10
|
+
return "public";
|
|
11
|
+
case "_auth":
|
|
12
|
+
return "auth";
|
|
13
|
+
case "_storage":
|
|
14
|
+
return "storage";
|
|
15
|
+
case "_platform":
|
|
16
|
+
return "platform";
|
|
17
|
+
default:
|
|
18
|
+
return physical;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function schemaMatches(filterSchema, payloadSchema) {
|
|
22
|
+
if (typeof payloadSchema !== "string") return false;
|
|
23
|
+
return filterSchema === payloadSchema || filterSchema === logicalSchema(payloadSchema);
|
|
24
|
+
}
|
|
6
25
|
var VALID_OPS = /* @__PURE__ */ new Set([
|
|
7
26
|
"eq",
|
|
8
27
|
"neq",
|
|
@@ -43,8 +62,11 @@ function parseColumnFilter(filterStr) {
|
|
|
43
62
|
value = stripped;
|
|
44
63
|
} else if (op === "is") {
|
|
45
64
|
if (value !== "null" && value !== "true" && value !== "false") return null;
|
|
65
|
+
} else if (op === "like" || op === "ilike") {
|
|
66
|
+
if (value.length > MAX_REGEX_PATTERN_LEN) return null;
|
|
46
67
|
} else if (op === "match" || op === "imatch") {
|
|
47
68
|
if (value.length > MAX_REGEX_PATTERN_LEN) return null;
|
|
69
|
+
if (hasCatastrophicBacktrackingRisk(value)) return null;
|
|
48
70
|
try {
|
|
49
71
|
new RegExp(value, op === "imatch" ? "i" : void 0);
|
|
50
72
|
} catch {
|
|
@@ -100,6 +122,12 @@ function compareNumeric(a, b, cmp) {
|
|
|
100
122
|
if (an === null || bn === null) return false;
|
|
101
123
|
return cmp(an, bn);
|
|
102
124
|
}
|
|
125
|
+
function hasCatastrophicBacktrackingRisk(pattern) {
|
|
126
|
+
const QUANT = "(?:[+*]|\\{\\d*,\\d*\\})";
|
|
127
|
+
if (new RegExp(`\\([^()]*${QUANT}[^()]*\\)${QUANT}`).test(pattern)) return true;
|
|
128
|
+
if (new RegExp(`\\([^()]*\\|[^()]*\\)${QUANT}`).test(pattern)) return true;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
103
131
|
function likeToRegExp(pattern, caseInsensitive) {
|
|
104
132
|
let re = "^";
|
|
105
133
|
for (const ch of pattern) {
|
|
@@ -191,9 +219,10 @@ function matchesColumnFilter(filter, record) {
|
|
|
191
219
|
|
|
192
220
|
// src/AuraChannel.ts
|
|
193
221
|
var DEFAULT_SUBSCRIBE_TIMEOUT_MS = 1e4;
|
|
222
|
+
var PERTE_D_ABONNEMENT = /Non abonné au canal|Accès révoqué au channel/;
|
|
194
223
|
var AuraChannel = class {
|
|
195
224
|
name;
|
|
196
|
-
|
|
225
|
+
_projectPrefix;
|
|
197
226
|
_listeners = [];
|
|
198
227
|
_subscribed = false;
|
|
199
228
|
_sendFn = null;
|
|
@@ -209,8 +238,18 @@ var AuraChannel = class {
|
|
|
209
238
|
_refCounter = 0;
|
|
210
239
|
/** Dernier event ID recu — utilise pour since_id au reconnect */
|
|
211
240
|
_lastEventId = null;
|
|
212
|
-
/** Pending ACK callbacks : ref_id →
|
|
241
|
+
/** Pending ACK callbacks : ref_id → solde la promesse de `send()` avec un statut. */
|
|
213
242
|
_pendingAcks = /* @__PURE__ */ new Map();
|
|
243
|
+
/**
|
|
244
|
+
* Abonnement vu par le SERVEUR — à ne pas confondre avec `_subscribed`, qui n'est que
|
|
245
|
+
* l'INTENTION de l'appelant (et pilote le re-subscribe après reconnexion).
|
|
246
|
+
*
|
|
247
|
+
* C18 (rejeu) : `_subscribed` était posé optimistement par `subscribe()` et n'était
|
|
248
|
+
* jamais infirmé quand le serveur REFUSAIT l'abonnement. `send({ ack })` armait alors un
|
|
249
|
+
* compte à rebours de 5 s pour un accusé structurellement impossible — mesuré sur le banc
|
|
250
|
+
* à 5 065 ms, alors que le serveur avait répondu en 6 ms « Non abonné au canal … ».
|
|
251
|
+
*/
|
|
252
|
+
_serverState = "pending";
|
|
214
253
|
/**
|
|
215
254
|
* Timer d'attente de la confirmation `subscribed`. Armé à l'ENVOI effectif du
|
|
216
255
|
* message subscribe (parité phoenix : `Push#startTimeout` ne démarre qu'au
|
|
@@ -221,9 +260,16 @@ var AuraChannel = class {
|
|
|
221
260
|
_subscribeTimeoutMs = DEFAULT_SUBSCRIBE_TIMEOUT_MS;
|
|
222
261
|
/** `CLOSED` n'est émis qu'une fois par cycle de vie du canal (unsubscribe idempotent). */
|
|
223
262
|
_closedEmitted = false;
|
|
224
|
-
|
|
263
|
+
/**
|
|
264
|
+
* @param projectPrefix Préfixe de projet du canal sur le fil (`"<project_id>:"`, ou `""`
|
|
265
|
+
* s'il est inconnu), résolu PARESSEUSEMENT : le SDK ne connaît le projet qu'à travers
|
|
266
|
+
* le token de session, qui peut arriver après la création du canal (connexion
|
|
267
|
+
* différée). Une valeur figée à la construction serait alors périmée pour toujours,
|
|
268
|
+
* le canal étant mémorisé par `RealtimeService`.
|
|
269
|
+
*/
|
|
270
|
+
constructor(name, projectPrefix, options) {
|
|
225
271
|
this.name = name;
|
|
226
|
-
this.
|
|
272
|
+
this._projectPrefix = projectPrefix;
|
|
227
273
|
this._isPrivate = options?.private ?? false;
|
|
228
274
|
this._broadcastAck = options?.broadcast?.ack ?? false;
|
|
229
275
|
this._broadcastSelf = options?.broadcast?.self ?? false;
|
|
@@ -235,7 +281,7 @@ var AuraChannel = class {
|
|
|
235
281
|
}
|
|
236
282
|
/** Full channel name with project prefix (for server protocol) */
|
|
237
283
|
get fullName() {
|
|
238
|
-
return `${this.
|
|
284
|
+
return `${this._projectPrefix()}${this.name}`;
|
|
239
285
|
}
|
|
240
286
|
on(type, filter, callback) {
|
|
241
287
|
this._listeners.push({ type, filter, callback });
|
|
@@ -295,6 +341,7 @@ var AuraChannel = class {
|
|
|
295
341
|
private: this._isPrivate
|
|
296
342
|
}
|
|
297
343
|
};
|
|
344
|
+
this._serverState = "pending";
|
|
298
345
|
this._sendFn(JSON.stringify(msg));
|
|
299
346
|
this._armSubscribeTimeout();
|
|
300
347
|
}
|
|
@@ -365,45 +412,84 @@ var AuraChannel = class {
|
|
|
365
412
|
}
|
|
366
413
|
// ─── Broadcast ──────────────────────────────────────────────────────────
|
|
367
414
|
/**
|
|
368
|
-
*
|
|
369
|
-
*
|
|
415
|
+
* Envoie un message broadcast sur le canal.
|
|
416
|
+
*
|
|
417
|
+
* Avec `broadcast: { ack: true }`, la promesse ne se résout qu'à l'accusé de réception du
|
|
418
|
+
* serveur (`broadcast_ack`) ; sans `ack`, elle se résout dès la remise au transport.
|
|
419
|
+
*
|
|
420
|
+
* Le statut rendu nomme sa CAUSE — voir {@link BroadcastSendStatus} :
|
|
421
|
+
* `'ok'`, `'timeout'`, `'not_connected'`, `'requires_subscription'`, `'error'`.
|
|
422
|
+
*
|
|
423
|
+
* **`'requires_subscription'`** signifie que le serveur n'a pas d'abonnement pour ce canal
|
|
424
|
+
* — soit `.subscribe()` n'a jamais été appelé, soit le serveur l'a REFUSÉ (RLS,
|
|
425
|
+
* `private_only`, quota) ou révoqué depuis. Rien n'est émis dans ce cas : le serveur
|
|
426
|
+
* rejetterait l'écriture (`Non abonné au canal '…' — Subscribe requis avant Broadcast`).
|
|
370
427
|
*
|
|
371
428
|
* @example
|
|
372
429
|
* ```ts
|
|
373
|
-
* //
|
|
430
|
+
* // Sans ACK (fire & forget)
|
|
374
431
|
* channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100, y: 200 } })
|
|
375
432
|
*
|
|
376
|
-
* //
|
|
377
|
-
*
|
|
433
|
+
* // Avec ACK — le modèle est celui de Supabase : on écrit APRÈS SUBSCRIBED.
|
|
434
|
+
* channel.subscribe(async (statut) => {
|
|
435
|
+
* if (statut !== 'SUBSCRIBED') return // CHANNEL_ERROR = le serveur a refusé ce canal
|
|
436
|
+
* const status = await channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100 } })
|
|
437
|
+
* if (status === 'requires_subscription') {
|
|
438
|
+
* // l'abonnement a été refusé ou révoqué entre-temps ; la cause a été remontée en
|
|
439
|
+
* // CHANNEL_ERROR à ce même callback.
|
|
440
|
+
* }
|
|
441
|
+
* })
|
|
378
442
|
* ```
|
|
379
443
|
*/
|
|
380
444
|
send(message) {
|
|
381
445
|
return new Promise((resolve) => {
|
|
382
446
|
if (!this._sendFn) {
|
|
383
|
-
resolve("
|
|
447
|
+
resolve("not_connected");
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (!this._subscribed || this._serverState === "refused") {
|
|
451
|
+
resolve("requires_subscription");
|
|
384
452
|
return;
|
|
385
453
|
}
|
|
386
454
|
const ref_id = message.ref_id ?? this._nextRef();
|
|
387
|
-
|
|
388
|
-
|
|
455
|
+
const ackExpected = this._broadcastAck;
|
|
456
|
+
let ackTimer = null;
|
|
457
|
+
if (ackExpected) {
|
|
458
|
+
ackTimer = setTimeout(() => {
|
|
389
459
|
this._pendingAcks.delete(ref_id);
|
|
390
460
|
resolve("timeout");
|
|
391
461
|
}, 5e3);
|
|
392
|
-
this._pendingAcks.set(ref_id, () => {
|
|
393
|
-
clearTimeout(
|
|
394
|
-
resolve(
|
|
462
|
+
this._pendingAcks.set(ref_id, (status) => {
|
|
463
|
+
if (ackTimer) clearTimeout(ackTimer);
|
|
464
|
+
resolve(status);
|
|
395
465
|
});
|
|
396
|
-
} else {
|
|
397
|
-
resolve("ok");
|
|
398
466
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
467
|
+
const cancelAck = () => {
|
|
468
|
+
if (ackTimer) clearTimeout(ackTimer);
|
|
469
|
+
this._pendingAcks.delete(ref_id);
|
|
470
|
+
};
|
|
471
|
+
let delivered;
|
|
472
|
+
try {
|
|
473
|
+
delivered = this._sendFn(
|
|
474
|
+
JSON.stringify({
|
|
475
|
+
type: "broadcast",
|
|
476
|
+
channel: this.fullName,
|
|
477
|
+
ref_id,
|
|
478
|
+
payload: { event: message.event, ...message.payload }
|
|
479
|
+
})
|
|
480
|
+
) !== false;
|
|
481
|
+
} catch {
|
|
482
|
+
cancelAck();
|
|
483
|
+
resolve("error");
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (!delivered) {
|
|
487
|
+
cancelAck();
|
|
488
|
+
resolve("not_connected");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (ackExpected) return;
|
|
492
|
+
resolve("ok");
|
|
407
493
|
});
|
|
408
494
|
}
|
|
409
495
|
// ─── Presence ───────────────────────────────────────────────────────────
|
|
@@ -416,19 +502,25 @@ var AuraChannel = class {
|
|
|
416
502
|
* await channel.track({ user: 'user-1', online_at: new Date().toISOString() })
|
|
417
503
|
* ```
|
|
418
504
|
*/
|
|
419
|
-
|
|
420
|
-
if (!this._sendFn) return "error";
|
|
421
|
-
this.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
505
|
+
track(state) {
|
|
506
|
+
if (!this._sendFn) return Promise.resolve("error");
|
|
507
|
+
if (!this._subscribed || this._serverState === "refused") return Promise.resolve("error");
|
|
508
|
+
try {
|
|
509
|
+
const delivered = this._sendFn(
|
|
510
|
+
JSON.stringify({
|
|
511
|
+
type: "presence_update",
|
|
512
|
+
channel: this.fullName,
|
|
513
|
+
meta: state
|
|
514
|
+
})
|
|
515
|
+
) !== false;
|
|
516
|
+
if (!delivered) return Promise.resolve("error");
|
|
517
|
+
} catch {
|
|
518
|
+
return Promise.resolve("error");
|
|
519
|
+
}
|
|
520
|
+
return Promise.resolve("ok");
|
|
429
521
|
}
|
|
430
|
-
/** Stop tracking presence. */
|
|
431
|
-
|
|
522
|
+
/** Stop tracking presence. Même contrat que `track()`. */
|
|
523
|
+
untrack() {
|
|
432
524
|
return this.track({});
|
|
433
525
|
}
|
|
434
526
|
/** Get the current presence state for this channel. */
|
|
@@ -442,6 +534,7 @@ var AuraChannel = class {
|
|
|
442
534
|
case "subscribed": {
|
|
443
535
|
const subMsg = msg;
|
|
444
536
|
if (subMsg.subscription_id) this._subscriptionId = subMsg.subscription_id;
|
|
537
|
+
this._serverState = "subscribed";
|
|
445
538
|
this._clearSubscribeTimeout();
|
|
446
539
|
this._emitStatus("SUBSCRIBED");
|
|
447
540
|
break;
|
|
@@ -481,8 +574,18 @@ var AuraChannel = class {
|
|
|
481
574
|
}
|
|
482
575
|
case "error": {
|
|
483
576
|
const errMsg = msg;
|
|
577
|
+
const errChannel = errMsg.channel;
|
|
578
|
+
if (errChannel && errChannel !== this.fullName && errChannel !== this.name) {
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
484
581
|
this._clearSubscribeTimeout();
|
|
485
582
|
this._emitStatus("CHANNEL_ERROR", new Error(errMsg.message ?? "Unknown error"));
|
|
583
|
+
const nommeCeCanal = errChannel !== void 0;
|
|
584
|
+
const perteDAbonnement = nommeCeCanal && (this._serverState !== "subscribed" || PERTE_D_ABONNEMENT.test(errMsg.message ?? ""));
|
|
585
|
+
if (perteDAbonnement) {
|
|
586
|
+
this._serverState = "refused";
|
|
587
|
+
this._settlePendingAcks("requires_subscription");
|
|
588
|
+
}
|
|
486
589
|
break;
|
|
487
590
|
}
|
|
488
591
|
case "token_refreshed":
|
|
@@ -491,16 +594,29 @@ var AuraChannel = class {
|
|
|
491
594
|
const ackMsg = msg;
|
|
492
595
|
const refId = ackMsg.ref_id ?? ackMsg.message_id;
|
|
493
596
|
if (refId) {
|
|
494
|
-
const
|
|
495
|
-
if (
|
|
597
|
+
const settle = this._pendingAcks.get(refId);
|
|
598
|
+
if (settle) {
|
|
496
599
|
this._pendingAcks.delete(refId);
|
|
497
|
-
|
|
600
|
+
settle("ok");
|
|
498
601
|
}
|
|
499
602
|
}
|
|
500
603
|
break;
|
|
501
604
|
}
|
|
502
605
|
}
|
|
503
606
|
}
|
|
607
|
+
/**
|
|
608
|
+
* Solde TOUS les accusés en attente de ce canal avec un statut donné.
|
|
609
|
+
*
|
|
610
|
+
* Le protocole ne corrèle pas l'erreur serveur au `ref_id` de l'écriture refusée : quand
|
|
611
|
+
* le serveur déclare n'avoir aucun abonnement pour ce canal, AUCUN accusé en vol ne peut
|
|
612
|
+
* plus aboutir — les solder tous est donc exact, pas approximatif.
|
|
613
|
+
*/
|
|
614
|
+
_settlePendingAcks(status) {
|
|
615
|
+
if (this._pendingAcks.size === 0) return;
|
|
616
|
+
const settlers = Array.from(this._pendingAcks.values());
|
|
617
|
+
this._pendingAcks.clear();
|
|
618
|
+
for (const settle of settlers) settle(status);
|
|
619
|
+
}
|
|
504
620
|
/** Dispatch a RealtimeEvent to matching listeners. */
|
|
505
621
|
_dispatchEvent(event) {
|
|
506
622
|
if (!event) return;
|
|
@@ -513,51 +629,49 @@ var AuraChannel = class {
|
|
|
513
629
|
if (eventType === "presence") {
|
|
514
630
|
const payload = event.payload;
|
|
515
631
|
const action = payload.action;
|
|
516
|
-
const
|
|
632
|
+
const clientId = payload.client_id;
|
|
633
|
+
const locate = () => {
|
|
634
|
+
for (const [key, arr] of Object.entries(this._presenceState)) {
|
|
635
|
+
const index = arr.findIndex((p) => p.client_id === clientId);
|
|
636
|
+
if (index >= 0) return { key, index };
|
|
637
|
+
}
|
|
638
|
+
return null;
|
|
639
|
+
};
|
|
517
640
|
if (action === "join") {
|
|
518
|
-
|
|
519
|
-
if (!
|
|
520
|
-
|
|
521
|
-
|
|
641
|
+
const key = payload.user_id || clientId;
|
|
642
|
+
if (!locate()) {
|
|
643
|
+
if (!this._presenceState[key]) this._presenceState[key] = [];
|
|
644
|
+
this._presenceState[key].push({
|
|
645
|
+
client_id: clientId,
|
|
522
646
|
user_id: payload.user_id ?? void 0,
|
|
523
647
|
metadata: payload.metadata ?? payload.meta ?? {},
|
|
524
648
|
joined_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
525
649
|
last_seen: (/* @__PURE__ */ new Date()).toISOString()
|
|
526
|
-
};
|
|
527
|
-
this._presenceState[key].push(presenceInfo);
|
|
650
|
+
});
|
|
528
651
|
}
|
|
529
652
|
} else if (action === "leave") {
|
|
530
|
-
const
|
|
531
|
-
if (
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
if (arr.length === 0) delete this._presenceState[key];
|
|
653
|
+
const found = locate();
|
|
654
|
+
if (found) {
|
|
655
|
+
const arr = this._presenceState[found.key];
|
|
656
|
+
arr.splice(found.index, 1);
|
|
657
|
+
if (arr.length === 0) delete this._presenceState[found.key];
|
|
535
658
|
}
|
|
536
659
|
} 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
|
-
}
|
|
660
|
+
const found = locate();
|
|
661
|
+
if (found) {
|
|
662
|
+
const entry = this._presenceState[found.key][found.index];
|
|
663
|
+
entry.metadata = payload.metadata ?? payload.meta ?? {};
|
|
664
|
+
entry.last_seen = (/* @__PURE__ */ new Date()).toISOString();
|
|
553
665
|
} else {
|
|
554
|
-
|
|
555
|
-
|
|
666
|
+
const key = payload.user_id || clientId;
|
|
667
|
+
if (!this._presenceState[key]) this._presenceState[key] = [];
|
|
668
|
+
this._presenceState[key].push({
|
|
669
|
+
client_id: clientId,
|
|
556
670
|
user_id: payload.user_id ?? void 0,
|
|
557
671
|
metadata: payload.metadata ?? payload.meta ?? {},
|
|
558
672
|
joined_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
559
673
|
last_seen: (/* @__PURE__ */ new Date()).toISOString()
|
|
560
|
-
}
|
|
674
|
+
});
|
|
561
675
|
}
|
|
562
676
|
}
|
|
563
677
|
}
|
|
@@ -568,7 +682,7 @@ var AuraChannel = class {
|
|
|
568
682
|
const filterEvent = filter.event.toLowerCase();
|
|
569
683
|
if (filterEvent === "*" || filterEvent === eventType) {
|
|
570
684
|
const payload = event.payload;
|
|
571
|
-
if (filter.schema &&
|
|
685
|
+
if (filter.schema && !schemaMatches(filter.schema, payload.schema)) continue;
|
|
572
686
|
if (filter.table && payload.table !== filter.table) continue;
|
|
573
687
|
if (filter.filter) {
|
|
574
688
|
const target = eventType === "delete" ? payload.old_record : payload.record;
|
|
@@ -621,6 +735,24 @@ var AuraChannel = class {
|
|
|
621
735
|
}
|
|
622
736
|
};
|
|
623
737
|
|
|
738
|
+
// src/errors.ts
|
|
739
|
+
var RealtimeConnectionError = class _RealtimeConnectionError extends Error {
|
|
740
|
+
/** Phase du cycle de vie où l'échec s'est produit. */
|
|
741
|
+
phase;
|
|
742
|
+
/** Code de fermeture WebSocket (RFC 6455 §7.4), si le runtime l'a fourni. */
|
|
743
|
+
code;
|
|
744
|
+
/** Raison de fermeture fournie par le serveur, si présente (souvent vide). */
|
|
745
|
+
reason;
|
|
746
|
+
constructor(phase, message, options) {
|
|
747
|
+
super(message, options?.cause !== void 0 ? { cause: options.cause } : void 0);
|
|
748
|
+
this.name = "RealtimeConnectionError";
|
|
749
|
+
this.phase = phase;
|
|
750
|
+
this.code = options?.code;
|
|
751
|
+
this.reason = options?.reason;
|
|
752
|
+
Object.setPrototypeOf(this, _RealtimeConnectionError.prototype);
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
|
|
624
756
|
// src/HeartbeatManager.ts
|
|
625
757
|
var HeartbeatManager = class _HeartbeatManager {
|
|
626
758
|
_worker = null;
|
|
@@ -764,29 +896,17 @@ function getTokenExpiryMs(token) {
|
|
|
764
896
|
if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
|
|
765
897
|
return exp * 1e3;
|
|
766
898
|
}
|
|
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
|
-
};
|
|
899
|
+
function getTokenProjectId(token) {
|
|
900
|
+
const payload = decodeJwtPayload(token);
|
|
901
|
+
const pid = payload?.project_id;
|
|
902
|
+
return typeof pid === "string" && pid !== "" ? pid : null;
|
|
903
|
+
}
|
|
785
904
|
|
|
786
905
|
// src/RealtimeService.ts
|
|
787
906
|
var HEARTBEAT_INTERVAL_MS = 25e3;
|
|
788
907
|
var MAX_TIMEOUT_MS = 2147483647;
|
|
789
908
|
var LIVENESS_GUARD_INTERVAL_MS = 3e4;
|
|
909
|
+
var CANAL_QUALIFIE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}:/;
|
|
790
910
|
var LIVENESS_MAX_SILENCE_MS = 9e4;
|
|
791
911
|
var RealtimeService = class _RealtimeService {
|
|
792
912
|
client;
|
|
@@ -895,6 +1015,23 @@ var RealtimeService = class _RealtimeService {
|
|
|
895
1015
|
}
|
|
896
1016
|
}
|
|
897
1017
|
}
|
|
1018
|
+
// ─── Préfixe de projet des canaux ───────────────────────────────────────
|
|
1019
|
+
/**
|
|
1020
|
+
* Préfixe `"<project_id>:"` des noms de canaux sur le fil, `""` s'il est inconnu.
|
|
1021
|
+
*
|
|
1022
|
+
* `aura-realtime` exige que tout canal commence par le `project_id` du TOKEN
|
|
1023
|
+
* (`handlers/websocket.rs`, `expected_prefix` ; idem pour le broadcast HTTP,
|
|
1024
|
+
* `handlers/broadcast.rs`). Le SDK ne porte plus d'identifiant de projet — le projet est
|
|
1025
|
+
* celui de l'URL — mais le token de session, lui, porte ce claim : on le lit là, dans le
|
|
1026
|
+
* même jeton que celui envoyé au serveur. Sans token (pas encore connecté), il n'y a rien
|
|
1027
|
+
* à préfixer ; la connexion WS échouerait de toute façon, le serveur exigeant un JWT.
|
|
1028
|
+
*/
|
|
1029
|
+
_projectPrefix() {
|
|
1030
|
+
const token = this.client.authStore.token;
|
|
1031
|
+
if (!token) return "";
|
|
1032
|
+
const pid = getTokenProjectId(token);
|
|
1033
|
+
return pid ? `${pid}:` : "";
|
|
1034
|
+
}
|
|
898
1035
|
// ─── WebSocket URL ──────────────────────────────────────────────────────
|
|
899
1036
|
get _wsUrl() {
|
|
900
1037
|
const base = this.client.baseUrl.replace(/^http/, "ws");
|
|
@@ -1104,11 +1241,21 @@ var RealtimeService = class _RealtimeService {
|
|
|
1104
1241
|
this._goDisconnected();
|
|
1105
1242
|
}
|
|
1106
1243
|
}
|
|
1107
|
-
/**
|
|
1244
|
+
/**
|
|
1245
|
+
* Send raw data through the WebSocket.
|
|
1246
|
+
*
|
|
1247
|
+
* @returns `true` si le message a été remis à la socket, `false` s'il a été **jeté**
|
|
1248
|
+
* (socket absente ou non-OPEN). Ce retour n'est pas cosmétique : sans lui,
|
|
1249
|
+
* `AuraChannel.send()`/`track()` rendaient `'ok'` pour un message qui n'avait jamais
|
|
1250
|
+
* quitté le process — la seule branche de `send()` capable de rendre `'error'` était
|
|
1251
|
+
* une exception que ce `_send` ne lève jamais (C18/N24).
|
|
1252
|
+
*/
|
|
1108
1253
|
_send(data) {
|
|
1109
1254
|
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
1110
1255
|
this._ws.send(data);
|
|
1256
|
+
return true;
|
|
1111
1257
|
}
|
|
1258
|
+
return false;
|
|
1112
1259
|
}
|
|
1113
1260
|
/** Refresh the JWT token on an existing connection (no disconnect). */
|
|
1114
1261
|
refreshToken(token) {
|
|
@@ -1149,29 +1296,72 @@ var RealtimeService = class _RealtimeService {
|
|
|
1149
1296
|
* Envoie un broadcast par HTTP (REST), sans connexion WebSocket ouverte
|
|
1150
1297
|
* (parité `@supabase/realtime-js`). Utile côté serveur/edge.
|
|
1151
1298
|
*
|
|
1152
|
-
* POST `/v1/realtime/
|
|
1299
|
+
* POST `/v1/realtime/broadcast` avec le contrat du handler backend
|
|
1153
1300
|
* (`broadcast.rs`) : `{ channel, payload }`, `channel` préfixé par le projet,
|
|
1154
1301
|
* `event` replié dans `payload` (aligné sur le broadcast WS).
|
|
1155
1302
|
*
|
|
1156
|
-
*
|
|
1303
|
+
* ### Le canal DOIT porter le projet — y compris hors session (K2)
|
|
1304
|
+
*
|
|
1305
|
+
* Le handler exige `channel.starts_with("{project_id}:")` et répond sinon
|
|
1306
|
+
* `403 « Le canal n'appartient pas à ce projet »`
|
|
1307
|
+
* (`services/aura-realtime/src/handlers/broadcast.rs`, fonction `broadcast`).
|
|
1308
|
+
*
|
|
1309
|
+
* Avec une session, ce préfixe est ajouté ici même : il est lu dans le claim
|
|
1310
|
+
* `project_id` du jeton (voir {@link _projectPrefix}).
|
|
1311
|
+
*
|
|
1312
|
+
* **Sans session — le cas d'usage serveur/edge de cette méthode** — le SDK n'a AUCUN
|
|
1313
|
+
* identifiant de projet : l'URL est la seule désignation du tenant côté client, et rien
|
|
1314
|
+
* ne la traduit en `project_id` (c'est la passerelle qui fait cette résolution, à partir
|
|
1315
|
+
* de l'hôte). L'authentification, elle, passe : pour le realtime, la passerelle forge un
|
|
1316
|
+
* JWT interne à partir des claims de la clé API et écrase l'`Authorization`
|
|
1317
|
+
* (`aura-gateway/src/proxy/sse_proxy.rs::do_http_proxy`). Émettre un canal nu produisait
|
|
1318
|
+
* donc un `403` dont le message ne nomme jamais la forme attendue.
|
|
1319
|
+
*
|
|
1320
|
+
* Cette méthode **refuse** désormais ce cas, sans appel réseau
|
|
1321
|
+
* (`error.code === 'realtime_channel_non_qualifie'`) : passez le canal déjà qualifié,
|
|
1322
|
+
* `"<project_id>:<canal>"`. Cette forme est acceptée dans les deux situations — avec une
|
|
1323
|
+
* session, elle n'est pas préfixée une seconde fois.
|
|
1324
|
+
*
|
|
1325
|
+
* @param channel Nom du canal. Avec une session : nom nu ou déjà qualifié. Sans session :
|
|
1326
|
+
* OBLIGATOIREMENT `"<project_id>:<canal>"`.
|
|
1157
1327
|
* @param event Nom de l'événement broadcast.
|
|
1158
1328
|
* @param payload Données de l'événement.
|
|
1159
|
-
* @returns `AuraResponse<null>` — `error` non nul en cas de 4xx/rate-limit
|
|
1329
|
+
* @returns `AuraResponse<null>` — `error` non nul en cas de 4xx/rate-limit, ou de canal
|
|
1330
|
+
* non qualifiable (refus local, aucun appel réseau).
|
|
1160
1331
|
*
|
|
1161
1332
|
* @example
|
|
1162
1333
|
* ```ts
|
|
1334
|
+
* // Navigateur, utilisateur connecté : le projet vient du jeton de session.
|
|
1163
1335
|
* await aura.realtime.httpBroadcast('room-1', 'cursor', { x: 10, y: 20 })
|
|
1336
|
+
*
|
|
1337
|
+
* // Serveur / edge (clé service_role, aucune session) : qualifier le canal.
|
|
1338
|
+
* await aura.realtime.httpBroadcast(`${PROJECT_ID}:room-1`, 'cursor', { x: 10, y: 20 })
|
|
1164
1339
|
* ```
|
|
1165
1340
|
*/
|
|
1166
1341
|
httpBroadcast(channel, event, payload = {}) {
|
|
1167
|
-
const prefix =
|
|
1168
|
-
|
|
1342
|
+
const prefix = this._projectPrefix();
|
|
1343
|
+
let fullChannel;
|
|
1344
|
+
if (prefix) {
|
|
1345
|
+
fullChannel = channel.startsWith(prefix) ? channel : `${prefix}${channel}`;
|
|
1346
|
+
} else if (CANAL_QUALIFIE.test(channel)) {
|
|
1347
|
+
fullChannel = channel;
|
|
1348
|
+
} else {
|
|
1349
|
+
return Promise.resolve({
|
|
1350
|
+
data: null,
|
|
1351
|
+
error: new AuraError(0, {
|
|
1352
|
+
error: {
|
|
1353
|
+
code: "realtime_channel_non_qualifie",
|
|
1354
|
+
message: `httpBroadcast: le canal "${channel}" n'est pas qualifi\xE9 et aucune session n'est ouverte \u2014 le SDK n'a donc aucun \`project_id\` \xE0 lui pr\xE9fixer, alors que le serveur l'EXIGE (403 \xAB Le canal n'appartient pas \xE0 ce projet \xBB). Passez le canal sous la forme "<project_id>:` + channel + `" \u2014 c'est la forme attendue c\xF4t\xE9 serveur/edge, o\xF9 il n'y a pas de session utilisateur.`
|
|
1355
|
+
}
|
|
1356
|
+
})
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1169
1359
|
const body = {
|
|
1170
1360
|
channel: fullChannel,
|
|
1171
1361
|
payload: { event, ...payload }
|
|
1172
1362
|
};
|
|
1173
1363
|
return fetchWithAuth(
|
|
1174
|
-
`${this.client.baseUrl}/v1/realtime
|
|
1364
|
+
`${this.client.baseUrl}/v1/realtime/broadcast`,
|
|
1175
1365
|
{ method: "POST", body },
|
|
1176
1366
|
this.client
|
|
1177
1367
|
);
|
|
@@ -1269,7 +1459,7 @@ var RealtimeService = class _RealtimeService {
|
|
|
1269
1459
|
channel(name, options) {
|
|
1270
1460
|
const existing = this._channels.get(name);
|
|
1271
1461
|
if (existing) return existing;
|
|
1272
|
-
const ch = new AuraChannel(name, this.
|
|
1462
|
+
const ch = new AuraChannel(name, () => this._projectPrefix(), options);
|
|
1273
1463
|
this._channels.set(name, ch);
|
|
1274
1464
|
this.connect();
|
|
1275
1465
|
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
@@ -1301,8 +1491,8 @@ var RealtimeService = class _RealtimeService {
|
|
|
1301
1491
|
_dispatch(msg) {
|
|
1302
1492
|
const channelFull = "channel" in msg ? msg.channel : "topic" in msg ? msg.topic : void 0;
|
|
1303
1493
|
if (channelFull) {
|
|
1304
|
-
const prefix =
|
|
1305
|
-
const name = channelFull.startsWith(prefix) ? channelFull.slice(prefix.length) : channelFull;
|
|
1494
|
+
const prefix = this._projectPrefix();
|
|
1495
|
+
const name = prefix && channelFull.startsWith(prefix) ? channelFull.slice(prefix.length) : channelFull;
|
|
1306
1496
|
const ch = this._channels.get(name);
|
|
1307
1497
|
if (ch) {
|
|
1308
1498
|
ch._onMessage(msg);
|
|
@@ -1314,6 +1504,15 @@ var RealtimeService = class _RealtimeService {
|
|
|
1314
1504
|
return;
|
|
1315
1505
|
}
|
|
1316
1506
|
}
|
|
1507
|
+
if (msg.type === "error") {
|
|
1508
|
+
if (typeof console !== "undefined") {
|
|
1509
|
+
console.warn(
|
|
1510
|
+
"[aura-realtime] Erreur serveur pour un canal inconnu de ce client \u2014 ignor\xE9e (aucun canal local concern\xE9) :",
|
|
1511
|
+
{ channel: channelFull, message: msg.message }
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1317
1516
|
}
|
|
1318
1517
|
if (msg.type === "token_refreshed") {
|
|
1319
1518
|
return;
|
|
@@ -1347,22 +1546,6 @@ var RealtimeService = class _RealtimeService {
|
|
|
1347
1546
|
}
|
|
1348
1547
|
}
|
|
1349
1548
|
}
|
|
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
1549
|
};
|
|
1367
1550
|
|
|
1368
1551
|
export { AuraChannel, DEFAULT_SUBSCRIBE_TIMEOUT_MS, HEARTBEAT_INTERVAL_MS, HeartbeatManager, RealtimeConnectionError, RealtimeService };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aurabase/realtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Service temps réel du SDK Aurabase (postgres_changes, broadcast, presence)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"realtime"
|
|
46
46
|
],
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@aurabase/core": "0.
|
|
48
|
+
"@aurabase/core": "0.5.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "20.14.15",
|