@aurabase/realtime 0.4.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.
@@ -0,0 +1,419 @@
1
+ import { ChannelConfig, PostgresChangesFilter, RealtimeEvent, BroadcastFilter, PresenceFilter, SubscribeCallback, BroadcastPayload, PresenceState, ServerMessage, RealtimeListenType, AuraClientLike, AuraResponse } from '@aurabase/core';
2
+
3
+ type ListenerCallback = (payload: RealtimeEvent | Record<string, unknown>) => void;
4
+ type ListenerEntry = {
5
+ type: RealtimeListenType;
6
+ filter: PostgresChangesFilter | BroadcastFilter | PresenceFilter;
7
+ callback: ListenerCallback;
8
+ };
9
+ type SendFn = (data: string) => void;
10
+ /**
11
+ * Délai par défaut d'attente de la confirmation `subscribed` avant d'émettre
12
+ * `TIMED_OUT`. Aligné sur `DEFAULT_TIMEOUT` de `@supabase/realtime-js`
13
+ * (`dist/main/lib/constants.js`, 10 000 ms).
14
+ */
15
+ declare const DEFAULT_SUBSCRIBE_TIMEOUT_MS = 10000;
16
+ /**
17
+ * A realtime channel with Supabase-compatible chainable API.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * const channel = aura.channel('room-1')
22
+ * .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, (payload) => {
23
+ * console.log('New message:', payload)
24
+ * })
25
+ * .on('broadcast', { event: 'cursor-pos' }, (payload) => {
26
+ * console.log('Cursor:', payload)
27
+ * })
28
+ * .on('presence', { event: 'sync' }, () => {
29
+ * console.log('Presence synced:', channel.presenceState())
30
+ * })
31
+ * .subscribe((status) => {
32
+ * if (status === 'SUBSCRIBED') console.log('Connected!')
33
+ * })
34
+ * ```
35
+ */
36
+ declare class AuraChannel {
37
+ readonly name: string;
38
+ private readonly _projectId;
39
+ private readonly _listeners;
40
+ private _subscribed;
41
+ private _sendFn;
42
+ private _subscribeCallback;
43
+ private _presenceState;
44
+ private _isPrivate;
45
+ private _broadcastAck;
46
+ private _broadcastSelf;
47
+ private _presenceKey;
48
+ /** ID de subscription retourné par le serveur (utilisé pour le filtre RLS allowed_sub_ids) */
49
+ private _subscriptionId;
50
+ /** Compteur pour générer des ref_id uniques par connexion */
51
+ private _refCounter;
52
+ /** Dernier event ID recu — utilise pour since_id au reconnect */
53
+ private _lastEventId;
54
+ /** Pending ACK callbacks : ref_id → resolve */
55
+ private _pendingAcks;
56
+ /**
57
+ * Timer d'attente de la confirmation `subscribed`. Armé à l'ENVOI effectif du
58
+ * message subscribe (parité phoenix : `Push#startTimeout` ne démarre qu'au
59
+ * `send()`), désarmé dès qu'une réponse — `subscribed` ou `error` — arrive.
60
+ */
61
+ private _subscribeTimer;
62
+ /** Délai avant `TIMED_OUT`, surchargeable par `subscribe(cb, timeout)`. */
63
+ private _subscribeTimeoutMs;
64
+ /** `CLOSED` n'est émis qu'une fois par cycle de vie du canal (unsubscribe idempotent). */
65
+ private _closedEmitted;
66
+ constructor(name: string, projectId: string, options?: ChannelConfig);
67
+ /** Génère un ref_id unique pour la corrélation request/response. */
68
+ private _nextRef;
69
+ /** Full channel name with project prefix (for server protocol) */
70
+ get fullName(): string;
71
+ /**
72
+ * Register a listener for a specific event type.
73
+ * Returns `this` for chaining.
74
+ */
75
+ on(type: 'postgres_changes', filter: PostgresChangesFilter, callback: (payload: RealtimeEvent) => void): this;
76
+ on(type: 'broadcast', filter: BroadcastFilter, callback: (payload: RealtimeEvent) => void): this;
77
+ on(type: 'presence', filter: PresenceFilter, callback: (payload: any) => void): this;
78
+ /**
79
+ * Subscribe to the channel. Sends the Subscribe message to the server
80
+ * with the full config (postgres_changes, broadcast, presence).
81
+ *
82
+ * Le callback reçoit les mêmes statuts que `@supabase/realtime-js` :
83
+ * - `SUBSCRIBED` — le serveur a confirmé l'abonnement ;
84
+ * - `CHANNEL_ERROR` — refus serveur (RLS, rate limit…), échec de handshake ou
85
+ * coupure inattendue de la connexion ;
86
+ * - `TIMED_OUT` — aucune réponse du serveur dans `timeoutMs` après l'envoi ;
87
+ * - `CLOSED` — le canal a été fermé volontairement (`unsubscribe()`,
88
+ * `removeChannel()`, `removeAllChannels()`, `disconnect()`).
89
+ *
90
+ * @param callback Callback de statut (optionnel).
91
+ * @param timeoutMs Délai avant `TIMED_OUT` (défaut 10 s, comme Supabase).
92
+ */
93
+ subscribe(callback?: SubscribeCallback, timeoutMs?: number): this;
94
+ /** Called by RealtimeService once the WS connection is ready. */
95
+ _bindSend(sendFn: SendFn): void;
96
+ /** Send the subscribe message to the server. */
97
+ _doSubscribe(): void;
98
+ /** (Re)arme le compte à rebours d'attente de la confirmation `subscribed`. */
99
+ private _armSubscribeTimeout;
100
+ private _clearSubscribeTimeout;
101
+ /**
102
+ * Signale une erreur de transport (échec de handshake, coupure inattendue,
103
+ * connexion morte) — appelé par `RealtimeService`.
104
+ *
105
+ * Émet `CHANNEL_ERROR`, comme `@supabase/realtime-js` : chez Supabase, la
106
+ * fermeture du socket déclenche `Socket#triggerChanError` (phoenix
107
+ * `socket.js:550`), qui produit `CHANNEL_ERROR` et **jamais** `CLOSED` —
108
+ * `CLOSED` reste réservé à la fermeture volontaire du canal.
109
+ */
110
+ _signalError(err: Error): void;
111
+ /** Unsubscribe from the channel. */
112
+ unsubscribe(): void;
113
+ /**
114
+ * Send a broadcast message to the channel.
115
+ * If broadcast.ack=true, returns a Promise that resolves when the server ACKs.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * // Without ACK (fire & forget)
120
+ * channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100, y: 200 } })
121
+ *
122
+ * // With ACK (wait for server confirmation)
123
+ * await channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100 } })
124
+ * ```
125
+ */
126
+ send(message: BroadcastPayload): Promise<'ok' | 'timeout' | 'error'>;
127
+ /**
128
+ * Track presence state for this client.
129
+ * Returns a Promise that resolves with 'ok' once tracked (compatible Supabase).
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * await channel.track({ user: 'user-1', online_at: new Date().toISOString() })
134
+ * ```
135
+ */
136
+ track(state: Record<string, unknown>): Promise<'ok' | 'error'>;
137
+ /** Stop tracking presence. */
138
+ untrack(): Promise<'ok' | 'error'>;
139
+ /** Get the current presence state for this channel. */
140
+ presenceState(): PresenceState;
141
+ /** Called by RealtimeService when a server message arrives for this channel. */
142
+ _onMessage(msg: ServerMessage): void;
143
+ /** Dispatch a RealtimeEvent to matching listeners. */
144
+ private _dispatchEvent;
145
+ /** Whether this channel has been subscribed. */
146
+ get isSubscribed(): boolean;
147
+ /** Get listeners (for re-subscribe after reconnect). */
148
+ _getListeners(): ListenerEntry[];
149
+ }
150
+
151
+ /**
152
+ * Phase du cycle de vie de la connexion WebSocket où l'erreur s'est produite.
153
+ *
154
+ * - `handshake` : la connexion s'est fermée AVANT d'avoir jamais été ouverte.
155
+ * Cause la plus fréquente : refus d'authentification du serveur
156
+ * (HTTP 401/403 sur `GET /v1/realtime/ws`). Peut aussi être un
157
+ * serveur injoignable, un DNS/TLS en échec ou un proxy qui coupe.
158
+ * - `connection` : la connexion était ouverte et s'est fermée de façon inattendue.
159
+ * - `liveness` : la connexion paraît ouverte mais le serveur ne répond plus
160
+ * (aucun message reçu entre deux heartbeats) — TCP à demi-fermé.
161
+ */
162
+ type RealtimeErrorPhase = 'handshake' | 'connection' | 'liveness';
163
+ /**
164
+ * Erreur de transport realtime, exploitable par l'appelant.
165
+ *
166
+ * ── Ce que cette erreur peut et ne peut PAS dire ─────────────────────────────
167
+ * La spécification WebSocket **interdit** au client JavaScript de lire le statut
168
+ * HTTP d'un handshake échoué : l'événement `error` est vide et le `close` porte
169
+ * systématiquement le code `1006` (« abnormal closure », aucune frame Close reçue).
170
+ * Vérifié sur la stack Aurabase locale avec Node 26/undici : un refus renvoyé par
171
+ * `aura-realtime` en `401 Unauthorized` (« Token JWT requis en query param
172
+ * ?token= ») arrive côté client sous la forme `error {}` puis `close 1006`.
173
+ *
174
+ * Cette classe ne fabrique donc jamais de code HTTP. Elle affirme uniquement ce
175
+ * qui est observable — la phase, le code de fermeture WebSocket, la raison — et
176
+ * nomme la cause la plus probable pour que l'appelant sache où chercher.
177
+ */
178
+ declare class RealtimeConnectionError extends Error {
179
+ /** Phase du cycle de vie où l'échec s'est produit. */
180
+ readonly phase: RealtimeErrorPhase;
181
+ /** Code de fermeture WebSocket (RFC 6455 §7.4), si le runtime l'a fourni. */
182
+ readonly code?: number;
183
+ /** Raison de fermeture fournie par le serveur, si présente (souvent vide). */
184
+ readonly reason?: string;
185
+ constructor(phase: RealtimeErrorPhase, message: string, options?: {
186
+ code?: number;
187
+ reason?: string;
188
+ cause?: unknown;
189
+ });
190
+ }
191
+
192
+ /** Connection state machine */
193
+ type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
194
+ /**
195
+ * Période du heartbeat client, en millisecondes.
196
+ *
197
+ * Choisie d'après le contrat RÉEL du serveur (`services/aura-realtime/src/handlers/
198
+ * websocket.rs`) et non d'après une convention supposée :
199
+ * - le serveur coupe toute connexion dont il n'a RIEN reçu depuis 60 s
200
+ * (`heartbeat_timeout`, l.373, évalué à chaque tick de `ping_interval` = 30 s) ;
201
+ * - il pousse de son côté un `{"type":"pong"}` non sollicité à chaque tick de 30 s
202
+ * (l.605), et répond immédiatement à un `{"type":"ping"}` client (l.1027-1030).
203
+ *
204
+ * 25 s tient donc largement sous les 60 s serveur, et coïncide avec
205
+ * `HEARTBEAT_INTERVAL` de `@supabase/realtime-js` (25 000 ms).
206
+ */
207
+ declare const HEARTBEAT_INTERVAL_MS = 25000;
208
+ /**
209
+ * Realtime service — manages a single WebSocket connection and multiplexes
210
+ * channels on top of it. Supabase-compatible API.
211
+ *
212
+ * @example
213
+ * ```ts
214
+ * const aura = createClient('http://localhost:8080', 'my-project')
215
+ *
216
+ * const channel = aura.realtime.channel('room-1')
217
+ * .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, (payload) => {
218
+ * console.log('New message:', payload)
219
+ * })
220
+ * .on('broadcast', { event: 'cursor-pos' }, (payload) => {
221
+ * console.log('Cursor:', payload)
222
+ * })
223
+ * .subscribe((status) => {
224
+ * if (status === 'SUBSCRIBED') {
225
+ * channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100, y: 200 } })
226
+ * }
227
+ * })
228
+ * ```
229
+ */
230
+ declare class RealtimeService {
231
+ private readonly client;
232
+ private _ws;
233
+ private _channels;
234
+ private _state;
235
+ private _stateListeners;
236
+ private _autoReconnect;
237
+ private _reconnectAttempts;
238
+ private _maxReconnectAttempts;
239
+ private _reconnectTimer;
240
+ private _intentionalClose;
241
+ private _heartbeat;
242
+ private _tokenRefreshTimer;
243
+ /** Dernier access_token propagé au serveur — évite les pushs redondants (propagation différentielle). */
244
+ private _lastSentToken;
245
+ /** Listeners d'erreurs de transport (handshake refusé, coupure, connexion morte). */
246
+ private _errorListeners;
247
+ /**
248
+ * `true` entre l'envoi d'un ping et la réception du message serveur suivant.
249
+ * S'il est encore `true` au tick de heartbeat suivant, aucun octet applicatif
250
+ * n'est revenu en 25 s alors que le serveur en pousse au moins un toutes les
251
+ * 30 s et répond au ping : la connexion est morte (TCP à demi-fermé).
252
+ */
253
+ private _awaitingPong;
254
+ /** Backoff delays in ms: 1s, 2s, 4s, 8s, 16s, 30s (cap) */
255
+ private static readonly BACKOFF_MS;
256
+ constructor(client: AuraClientLike);
257
+ /** Current connection state. */
258
+ get connectionState(): ConnectionState;
259
+ /** Listen for connection state changes. */
260
+ onStateChange(callback: (state: ConnectionState) => void): () => void;
261
+ private _setState;
262
+ /**
263
+ * Écoute les erreurs de transport de la connexion realtime.
264
+ *
265
+ * Complète `onStateChange` (qui ne dit que « ça a bougé ») en fournissant la
266
+ * CAUSE : handshake refusé (typiquement un 401 sur le token/apikey), coupure
267
+ * inattendue, ou connexion morte détectée par le heartbeat. Sans ce canal,
268
+ * un refus d'authentification était totalement silencieux — la boucle de
269
+ * reconnexion tournait indéfiniment sans que l'appelant puisse le savoir.
270
+ *
271
+ * @returns Une fonction de désabonnement.
272
+ *
273
+ * @example
274
+ * ```ts
275
+ * aura.realtime.onError((err) => {
276
+ * if (err.phase === 'handshake') {
277
+ * // token expiré / apikey invalide → renouveler la session
278
+ * }
279
+ * })
280
+ * ```
281
+ */
282
+ onError(callback: (err: RealtimeConnectionError) => void): () => void;
283
+ /**
284
+ * Diffuse une erreur de transport : aux listeners `onError`, puis à tous les
285
+ * canaux sous forme de `CHANNEL_ERROR` (mécanisme officiel Supabase — chez
286
+ * phoenix, `Socket#triggerChanError` fait exactement cela).
287
+ *
288
+ * Chaque callback est isolé : un listener qui jette ne doit jamais interrompre
289
+ * la diffusion ni la boucle de reconnexion.
290
+ */
291
+ private _emitError;
292
+ private get _wsUrl();
293
+ /** Ensure the WebSocket connection is open. */
294
+ connect(): void;
295
+ /**
296
+ * Détache le socket courant et remet à zéro tout ce qui lui était attaché.
297
+ * Partagé par `onclose` et par la détection de connexion morte (qui ne peut PAS
298
+ * compter sur un `onclose` : une socket à demi-fermée n'en émet parfois jamais).
299
+ */
300
+ private _detachSocket;
301
+ /**
302
+ * Un tick de heartbeat : vérifie la liveness du lien puis émet un ping.
303
+ *
304
+ * Règle (calquée sur `Socket#sendHeartbeat`/`heartbeatTimeout` de phoenix, que
305
+ * `@supabase/realtime-js` réutilise) : si le ping du tick précédent n'a été suivi
306
+ * d'AUCUN message serveur, la connexion est morte. Le contrat serveur rend cette
307
+ * règle sûre — `aura-realtime` répond au ping immédiatement ET pousse un pong
308
+ * spontané toutes les 30 s : 25 s de silence total ne peuvent pas être normaux.
309
+ */
310
+ private _heartbeatTick;
311
+ /**
312
+ * Connexion « zombie » : ouverte du point de vue du système, muette en pratique.
313
+ *
314
+ * On ne peut pas se contenter d'appeler `close()` et d'attendre le `onclose` :
315
+ * sur un TCP à demi-fermé, la frame Close part dans le vide et l'événement peut
316
+ * n'arriver que bien plus tard (voire jamais). On détache donc le socket
317
+ * IMMÉDIATEMENT — la garde `ws !== this._ws` neutralisera son `onclose` tardif —
318
+ * puis on relance le cycle de reconnexion nous-mêmes.
319
+ */
320
+ private _handleDeadConnection;
321
+ /** Send raw data through the WebSocket. */
322
+ private _send;
323
+ /** Refresh the JWT token on an existing connection (no disconnect). */
324
+ refreshToken(token: string): void;
325
+ /**
326
+ * Définit/rafraîchit explicitement le token utilisé pour l'autorisation des
327
+ * canaux privés et broadcast (parité `@supabase/realtime-js` `setAuth`).
328
+ *
329
+ * - Avec `token` : utilise ce token. Sinon, reprend `client.authStore.token`.
330
+ * - Propage via le pipeline existant `refreshToken` (push
331
+ * `{ type:'access_token', token }` sur une connexion ouverte, dédupliqué via
332
+ * `_lastSentToken`). N'ouvre ni ne ferme aucune connexion WS.
333
+ * - Hors-ligne : `connect()` lit `client.authStore.token` au moment de l'open,
334
+ * donc `setAuth()` doit être appelé avec un token présent dans le store (ou
335
+ * après authentification) pour prendre effet sur une future connexion.
336
+ *
337
+ * À appeler AVANT de s'abonner à un canal privé (`channel(name,{private:true})`).
338
+ *
339
+ * @example
340
+ * ```ts
341
+ * aura.realtime.setAuth(session.access_token)
342
+ * const ch = aura.realtime.channel('secret', { private: true }).subscribe()
343
+ * ```
344
+ */
345
+ setAuth(token?: string): void;
346
+ /**
347
+ * Envoie un broadcast par HTTP (REST), sans connexion WebSocket ouverte
348
+ * (parité `@supabase/realtime-js`). Utile côté serveur/edge.
349
+ *
350
+ * POST `/v1/realtime/{projectId}/broadcast` avec le contrat du handler backend
351
+ * (`broadcast.rs`) : `{ channel, payload }`, `channel` préfixé par le projet,
352
+ * `event` replié dans `payload` (aligné sur le broadcast WS).
353
+ *
354
+ * @param channel Nom du canal (préfixé projet automatiquement si absent).
355
+ * @param event Nom de l'événement broadcast.
356
+ * @param payload Données de l'événement.
357
+ * @returns `AuraResponse<null>` — `error` non nul en cas de 4xx/rate-limit.
358
+ *
359
+ * @example
360
+ * ```ts
361
+ * await aura.realtime.httpBroadcast('room-1', 'cursor', { x: 10, y: 20 })
362
+ * ```
363
+ */
364
+ httpBroadcast(channel: string, event: string, payload?: Record<string, unknown>): Promise<AuraResponse<null>>;
365
+ /**
366
+ * Schedule proactive token refresh at 80% of the JWT TTL.
367
+ * Prevents token expiration during an active WebSocket session.
368
+ */
369
+ private _scheduleTokenRefresh;
370
+ /** Schedule a reconnection attempt with exponential backoff + jitter. */
371
+ private _scheduleReconnect;
372
+ /** Enable/disable auto-reconnect (enabled by default). */
373
+ setAutoReconnect(enabled: boolean, maxAttempts?: number): void;
374
+ /** Disconnect the WebSocket and clean up all channels. */
375
+ disconnect(): void;
376
+ /**
377
+ * Create or retrieve a channel by name.
378
+ * Returns an `AuraChannel` with chainable `.on()` / `.subscribe()` API.
379
+ *
380
+ * @example
381
+ * ```ts
382
+ * const channel = aura.realtime.channel('room-1')
383
+ * ```
384
+ */
385
+ channel(name: string, options?: ChannelConfig): AuraChannel;
386
+ /** Remove a channel. */
387
+ removeChannel(name: string): void;
388
+ /** List active channels. */
389
+ getChannels(): AuraChannel[];
390
+ /** Remove all channels and subscriptions. */
391
+ removeAllChannels(): void;
392
+ /** Route incoming server messages to the correct channel. */
393
+ private _dispatch;
394
+ /**
395
+ * Simple callback-based subscription (legacy API).
396
+ * Prefer using `.channel().on().subscribe()` instead.
397
+ *
398
+ * @deprecated Use `channel().on().subscribe()` instead.
399
+ */
400
+ on<T = Record<string, unknown>>(channelName: string, callback: (event: any) => void): {
401
+ unsubscribe: () => void;
402
+ };
403
+ }
404
+
405
+ /**
406
+ * HeartbeatManager — pings périodiques pour maintenir la connexion WS.
407
+ * Utilise un Worker dédié (anti-throttle des onglets en arrière-plan) ; fallback setInterval.
408
+ */
409
+ declare class HeartbeatManager {
410
+ private _worker;
411
+ private _workerUrl;
412
+ private _interval;
413
+ private _sendFn;
414
+ start(sendPing: () => void, intervalMs?: number): void;
415
+ stop(): void;
416
+ get isActive(): boolean;
417
+ }
418
+
419
+ export { AuraChannel, type ConnectionState, DEFAULT_SUBSCRIBE_TIMEOUT_MS, HEARTBEAT_INTERVAL_MS, HeartbeatManager, RealtimeConnectionError, type RealtimeErrorPhase, RealtimeService };