@vxil/realtime 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 techmaker.io
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @vxil/realtime
2
+
3
+ Reconnecting realtime client for [Vxil](https://vxil.com) channels — auto-reconnect with backoff, pre-expiry token refresh, an offline send buffer, presence helpers, and a typed event API. Runs in the browser and in Node.
4
+
5
+ ```bash
6
+ npm install @vxil/realtime
7
+ ```
8
+
9
+ Or zero-install in the browser — the same client is served as a self-contained module:
10
+
11
+ ```html
12
+ <script type="module">
13
+ import { RealtimeClient } from 'https://vxil.com/realtime.mjs';
14
+ </script>
15
+ ```
16
+
17
+ ## The one rule: connect tokens, not API keys
18
+
19
+ Your Vxil API key is a **server-side secret — never put it in a browser bundle**. A browser connects with a short-lived, scoped **connect token** instead: your backend (which holds the key) mints one via `POST /v1/realtime/tokens` and hands it to the client. `RealtimeClient` takes that minting step as a `tokenProvider` callback and re-mints automatically whenever the cached token nears expiry.
20
+
21
+ ```ts
22
+ import { RealtimeClient } from '@vxil/realtime';
23
+
24
+ const rt = new RealtimeClient({
25
+ // Implemented against YOUR backend — the browser never sees the API key.
26
+ tokenProvider: async ({ channel }) => {
27
+ const res = await fetch('/api/realtime-token', {
28
+ method: 'POST',
29
+ headers: { 'content-type': 'application/json' },
30
+ body: JSON.stringify({ channel }),
31
+ });
32
+ return res.json(); // { connect_path, expires_at } from POST /v1/realtime/tokens
33
+ },
34
+ });
35
+
36
+ const room = rt.channel('room-7');
37
+ await room.subscribe();
38
+
39
+ room.on('message.created', (frame) => console.log(frame.data));
40
+ room.send({ type: 'message', text: 'hello' }); // buffered while offline, flushed on reconnect
41
+ ```
42
+
43
+ The `tokenProvider` result may be any one of `connect_url` (full URL), `connect_path` (as returned by the tokens endpoint), or a raw `token`; include `expires_at` to enable pre-expiry re-minting. Server-side (Node) callers can use the `vxil.realtime.tokenProvider({ user_id })` glue from `@vxil/sdk` — it returns a function matching this shape.
44
+
45
+ ## What it adds over a raw WebSocket
46
+
47
+ - **Auto-reconnect** — exponential backoff with jitter, per channel (`reconnect: { baseDelayMs, maxDelayMs, maxAttempts }`); each channel owns its own socket, so reconnecting is re-subscribing.
48
+ - **Token refresh** — re-mints via your `tokenProvider` before `expires_at` (tunable with `expirySkewMs`) and after any close, so an expired token never strands a client.
49
+ - **Offline send buffer** — `send()` while disconnected buffers FIFO (default 100 frames, drop-oldest; `sendBufferSize: 0` disables) and flushes on the next open. `send()` returns `false` when a frame was dropped instead of queued.
50
+ - **Presence** — `channel.presence.members` is a live roster map, re-synced from the server on every (re)connect; `channel.presence.on('sync' | 'join' | 'leave' | 'typing', cb)` for events. `channel.typing()` sends an ephemeral (never-buffered) typing signal.
51
+ - **Typed events** — `channel.on('<event>', cb)` or `channel.on('*', cb)`, each returning a disposer; `channel.onState(cb)` observes `idle → connecting → open → reconnecting → closed`.
52
+
53
+ ## Node
54
+
55
+ Node ≥ 22 has a global `WebSocket` and works out of the box. On older Node, inject a constructor:
56
+
57
+ ```ts
58
+ import WebSocket from 'ws';
59
+ const rt = new RealtimeClient({ tokenProvider, WebSocket });
60
+ ```
61
+
62
+ (`ws` is **not** a dependency of this package.)
63
+
64
+ Docs: [vxil.com](https://vxil.com) · Dashboard: [vxil.com/dashboard](https://vxil.com/dashboard)
65
+
66
+ MIT © techmaker.io
@@ -0,0 +1,159 @@
1
+ export type TokenProviderResult = {
2
+ /** Raw connect token — the client builds `/v1/realtime/connect?token=…`. */
3
+ token?: string;
4
+ /** As returned by POST /v1/realtime/tokens (starts with /v1/realtime/connect). */
5
+ connect_path?: string;
6
+ /** Full ws(s):// URL — wins over the other two if present. */
7
+ connect_url?: string;
8
+ /** ISO timestamp; enables pre-expiry re-mint. Absent ⇒ re-mint every attempt. */
9
+ expires_at?: string;
10
+ };
11
+ /** Your app implements this against YOUR backend (which holds the tenant API
12
+ * key and calls POST /v1/realtime/tokens). Server-side (Node) callers can use
13
+ * the `vxil.realtime.tokenProvider({ user_id })` glue from @vxil/sdk — it
14
+ * returns a function matching this type structurally. */
15
+ export type TokenProvider = (ctx: {
16
+ channel: string;
17
+ }) => Promise<TokenProviderResult>;
18
+ /** Minimal structural WebSocket surface the client needs — the real browser /
19
+ * Node-22 `WebSocket` and the `ws` package both satisfy it. */
20
+ export interface MinimalWebSocket {
21
+ send(data: string): void;
22
+ close(code?: number, reason?: string): void;
23
+ /** WHATWG readyState (0..3). Optional so hand-rolled test fakes without it
24
+ * still satisfy the interface — absent reads as "trust the state machine". */
25
+ readyState?: number;
26
+ addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (ev: {
27
+ data?: unknown;
28
+ code?: number;
29
+ }) => void): void;
30
+ }
31
+ export type WebSocketCtor = new (url: string) => MinimalWebSocket;
32
+ export interface RealtimeOptions {
33
+ tokenProvider: TokenProvider;
34
+ /** REST/WS origin; default 'https://api.vxil.com' — the served realtime.mjs
35
+ * env-bakes this literal to the right edge per environment, exactly like
36
+ * sdk.mjs. */
37
+ baseUrl?: string;
38
+ /** Node<22 injection: `import WebSocket from 'ws'` and pass it here. */
39
+ WebSocket?: WebSocketCtor;
40
+ reconnect?: {
41
+ baseDelayMs?: number;
42
+ maxDelayMs?: number;
43
+ /** default Infinity; exhausted → state 'closed' + error detail emit */
44
+ maxAttempts?: number;
45
+ };
46
+ /** Frames buffered by send() while disconnected; default 100; 0 disables. */
47
+ sendBufferSize?: number;
48
+ /** Re-mint when the cached token is within this of expires_at. Default 5000. */
49
+ expirySkewMs?: number;
50
+ }
51
+ export type ChannelState = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';
52
+ export interface RealtimeFrame {
53
+ event: string;
54
+ data?: unknown;
55
+ ts?: number;
56
+ }
57
+ export type PresenceEventName = 'sync' | 'join' | 'leave' | 'typing';
58
+ export interface PresenceEvent {
59
+ user_id?: string;
60
+ members?: ReadonlyMap<string, number>;
61
+ }
62
+ type FrameListener = (frame: RealtimeFrame) => void;
63
+ type StateListener = (state: ChannelState, detail?: {
64
+ attempt?: number;
65
+ error?: unknown;
66
+ }) => void;
67
+ type PresenceListener = (d: PresenceEvent) => void;
68
+ export declare class Channel {
69
+ readonly name: string;
70
+ private readonly opts;
71
+ private readonly WS;
72
+ private readonly baseDelayMs;
73
+ private readonly maxDelayMs;
74
+ private readonly maxAttempts;
75
+ private readonly bufferSize;
76
+ private readonly expirySkewMs;
77
+ private _state;
78
+ private ws;
79
+ private attempt;
80
+ private timer;
81
+ private userClosed;
82
+ private cached;
83
+ private buffer;
84
+ /** true between (re)open and the first server frame — gates the buffer flush */
85
+ private awaitingFlush;
86
+ private flushFallback;
87
+ private openPromise;
88
+ private resolveOpen;
89
+ private rejectOpen;
90
+ private everOpened;
91
+ private readonly listeners;
92
+ private readonly stateListeners;
93
+ private readonly presenceListeners;
94
+ private readonly membersMap;
95
+ /** Presence helpers. `members` is client-side bookkeeping: seeded/replaced by
96
+ * the server's `presence.state` on EVERY open, mutated by join/leave in
97
+ * between — best-effort between syncs (multi-tab counts can drift; the next
98
+ * reconnect re-syncs). */
99
+ readonly presence: {
100
+ members: ReadonlyMap<string, number>;
101
+ on(ev: PresenceEventName, cb: PresenceListener): () => void;
102
+ };
103
+ constructor(name: string, opts: RealtimeOptions, ws: WebSocketCtor);
104
+ get state(): ChannelState;
105
+ /** Open (or re-open after unsubscribe) the channel. Resolves on the first
106
+ * 'open'; rejects if maxAttempts is exhausted before ever opening, or on
107
+ * unsubscribe()/close() while still connecting. */
108
+ subscribe(): Promise<void>;
109
+ /** Deliberate close: state → 'closed', no reconnect, buffer kept as-is. */
110
+ unsubscribe(): void;
111
+ /** Listen for frames by exact event name, or '*' for everything. Returns a
112
+ * disposer. `pong` frames are internal and never dispatched. */
113
+ on(event: string, cb: FrameListener): () => void;
114
+ off(event: string, cb: FrameListener): void;
115
+ onState(cb: StateListener): () => void;
116
+ /** Send a client frame. Open → sent now (true). Disconnected → buffered
117
+ * FIFO up to sendBufferSize, flushed on the next open; when the buffer
118
+ * overflows the OLDEST frame is dropped and send() returns false (so does
119
+ * a send with buffering disabled). Frames over 1024 chars are still sent
120
+ * but the server will drop them (a console.warn flags it). */
121
+ send(frame: object): boolean;
122
+ /** Ephemeral typing signal — never buffered (replaying a stale typing
123
+ * indicator after a reconnect would be wrong). Server-gated on presence. */
124
+ typing(): void;
125
+ /** Ephemeral keepalive — never buffered. Server replies `pong` (swallowed). */
126
+ ping(): void;
127
+ private sendEphemeral;
128
+ private setState;
129
+ private settleOpen;
130
+ /** Cached-or-fresh connect URL. Re-mints via the tokenProvider when the
131
+ * cached token is absent, has no expires_at, or is within expirySkewMs of
132
+ * expiry — which also covers the "server rejected the token" case: a
133
+ * browser only ever sees close 1006, and by the time that matters the
134
+ * token is expired or near-expiry and gets re-minted on the next attempt. */
135
+ private resolveUrl;
136
+ private urlFrom;
137
+ private connect;
138
+ private handleOpen;
139
+ /** Flush the offline buffer FIFO onto the current socket (idempotent). */
140
+ private flushBuffer;
141
+ private scheduleReconnect;
142
+ private handleMessage;
143
+ private dispatch;
144
+ private emitPresence;
145
+ private handlePresence;
146
+ }
147
+ export declare class RealtimeClient {
148
+ private readonly opts;
149
+ private readonly WS;
150
+ private readonly channels;
151
+ constructor(opts: RealtimeOptions);
152
+ /** Idempotent registry: the same name always returns the same handle. */
153
+ channel(name: string): Channel;
154
+ /** Unsubscribes every channel (deliberate close — no reconnects). */
155
+ close(): void;
156
+ }
157
+ /** Convenience mirroring the served-file import path. */
158
+ export declare function createRealtimeClient(opts: RealtimeOptions): RealtimeClient;
159
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,450 @@
1
+ // @vxil/realtime — reconnecting client for Vxil realtime channels.
2
+ //
3
+ // ZERO-IMPORT SINGLE FILE — DO NOT ADD `import` STATEMENTS. This file is
4
+ // compiled with plain tsc into one dist/index.js which workers/web copies
5
+ // verbatim to public/realtime.mjs for the no-install browser path
6
+ // (`import { RealtimeClient } from 'https://vxil.com/realtime.mjs'`). Any
7
+ // import would leave a dangling module specifier in the served file and
8
+ // break every zero-install user (same invariant as @vxil/sdk → sdk.mjs; the
9
+ // staging e2e asserts the served asset stays self-contained).
10
+ //
11
+ // What it adds over the raw `vxil.realtime.connect()` socket:
12
+ // - auto-reconnect with exponential backoff + jitter,
13
+ // - token refresh: re-mints via the tokenProvider before TTL expiry and on
14
+ // any close (browsers cannot distinguish 401/429/network on a failed
15
+ // upgrade — every failure is close code 1006, so the client simply
16
+ // re-mints when the cached token is near/past expiry and keeps backing
17
+ // off; a channel at its connection cap looks identical and also resolves
18
+ // itself by backoff),
19
+ // - bounded offline send buffer flushed FIFO on reconnect,
20
+ // - automatic channel re-subscribe (each channel owns its own socket, so
21
+ // reconnecting IS re-subscribing),
22
+ // - presence helpers (roster map + join/leave/typing/sync events, re-synced
23
+ // from the server's presence.state seed on every open — counts between
24
+ // syncs are best-effort),
25
+ // - a typed event-emitter API.
26
+ //
27
+ // WebSocket: uses the global `WebSocket` (browser / Node ≥22). On older Node,
28
+ // pass a constructor — `import WebSocket from 'ws'; new RealtimeClient({
29
+ // ..., WebSocket })` — instead of failing with a silent `WebSocket is not
30
+ // defined`. (The `ws` package is NOT a dependency of this package.)
31
+ const DEFAULT_BASE = 'https://api.vxil.com';
32
+ function resolveCtor(injected) {
33
+ const WS = injected ?? globalThis.WebSocket;
34
+ if (!WS) {
35
+ throw new Error('No WebSocket available. In the browser it is global; on Node <22 pass one: ' +
36
+ "import WebSocket from 'ws'; new RealtimeClient({ ..., WebSocket }).");
37
+ }
38
+ return WS;
39
+ }
40
+ export class Channel {
41
+ name;
42
+ opts;
43
+ WS;
44
+ baseDelayMs;
45
+ maxDelayMs;
46
+ maxAttempts;
47
+ bufferSize;
48
+ expirySkewMs;
49
+ _state = 'idle';
50
+ ws = null;
51
+ attempt = 0; // number of the UPCOMING retry; reset to 0 on open
52
+ timer = null;
53
+ userClosed = false;
54
+ cached = null;
55
+ buffer = [];
56
+ /** true between (re)open and the first server frame — gates the buffer flush */
57
+ awaitingFlush = false;
58
+ flushFallback;
59
+ openPromise = null;
60
+ resolveOpen = null;
61
+ rejectOpen = null;
62
+ everOpened = false;
63
+ listeners = new Map();
64
+ stateListeners = new Set();
65
+ presenceListeners = new Map();
66
+ membersMap = new Map();
67
+ /** Presence helpers. `members` is client-side bookkeeping: seeded/replaced by
68
+ * the server's `presence.state` on EVERY open, mutated by join/leave in
69
+ * between — best-effort between syncs (multi-tab counts can drift; the next
70
+ * reconnect re-syncs). */
71
+ presence;
72
+ constructor(name, opts, ws) {
73
+ this.name = name;
74
+ this.opts = opts;
75
+ this.WS = ws;
76
+ this.baseDelayMs = opts.reconnect?.baseDelayMs ?? 500;
77
+ this.maxDelayMs = opts.reconnect?.maxDelayMs ?? 30_000;
78
+ this.maxAttempts = opts.reconnect?.maxAttempts ?? Infinity;
79
+ this.bufferSize = opts.sendBufferSize ?? 100;
80
+ this.expirySkewMs = opts.expirySkewMs ?? 5_000;
81
+ this.presence = {
82
+ members: this.membersMap,
83
+ on: (ev, cb) => {
84
+ const set = this.presenceListeners.get(ev) ?? new Set();
85
+ this.presenceListeners.set(ev, set);
86
+ set.add(cb);
87
+ return () => set.delete(cb);
88
+ },
89
+ };
90
+ }
91
+ get state() {
92
+ return this._state;
93
+ }
94
+ /** Open (or re-open after unsubscribe) the channel. Resolves on the first
95
+ * 'open'; rejects if maxAttempts is exhausted before ever opening, or on
96
+ * unsubscribe()/close() while still connecting. */
97
+ subscribe() {
98
+ if (this._state === 'open')
99
+ return Promise.resolve();
100
+ if (this.openPromise)
101
+ return this.openPromise;
102
+ this.openPromise = new Promise((res, rej) => {
103
+ this.resolveOpen = res;
104
+ this.rejectOpen = rej;
105
+ });
106
+ // A pending subscribe() the caller chose not to await must not surface as
107
+ // an unhandled rejection; attaching one internal handler marks it handled
108
+ // for everyone while still rejecting awaiting callers.
109
+ this.openPromise.catch(() => { });
110
+ if (this._state === 'idle' || this._state === 'closed') {
111
+ this.userClosed = false;
112
+ this.attempt = 0;
113
+ this.everOpened = false;
114
+ void this.connect();
115
+ }
116
+ return this.openPromise;
117
+ }
118
+ /** Deliberate close: state → 'closed', no reconnect, buffer kept as-is. */
119
+ unsubscribe() {
120
+ this.userClosed = true;
121
+ if (this.timer !== null) {
122
+ clearTimeout(this.timer);
123
+ this.timer = null;
124
+ }
125
+ const ws = this.ws;
126
+ this.ws = null;
127
+ if (ws) {
128
+ try {
129
+ ws.close(1000, 'unsubscribe');
130
+ }
131
+ catch {
132
+ /* already closing */
133
+ }
134
+ }
135
+ this.settleOpen(new Error('unsubscribed'));
136
+ this.setState('closed');
137
+ }
138
+ /** Listen for frames by exact event name, or '*' for everything. Returns a
139
+ * disposer. `pong` frames are internal and never dispatched. */
140
+ on(event, cb) {
141
+ const set = this.listeners.get(event) ?? new Set();
142
+ this.listeners.set(event, set);
143
+ set.add(cb);
144
+ return () => this.off(event, cb);
145
+ }
146
+ off(event, cb) {
147
+ this.listeners.get(event)?.delete(cb);
148
+ }
149
+ onState(cb) {
150
+ this.stateListeners.add(cb);
151
+ return () => this.stateListeners.delete(cb);
152
+ }
153
+ /** Send a client frame. Open → sent now (true). Disconnected → buffered
154
+ * FIFO up to sendBufferSize, flushed on the next open; when the buffer
155
+ * overflows the OLDEST frame is dropped and send() returns false (so does
156
+ * a send with buffering disabled). Frames over 1024 chars are still sent
157
+ * but the server will drop them (a console.warn flags it). */
158
+ send(frame) {
159
+ const text = JSON.stringify(frame);
160
+ if (text.length > 1024) {
161
+ console.warn(`[@vxil/realtime] frame is ${text.length} chars; the server drops frames over 1024`);
162
+ }
163
+ // readyState guard: WHATWG send() THROWS only on CONNECTING — on a
164
+ // CLOSING/CLOSED socket it silently discards. Between a transport-level
165
+ // close and the 'close' event reaching us (seen ~10s on staging: the DO
166
+ // completes the close handshake lazily) this client still believes it is
167
+ // 'open'; without the guard a send() in that window "succeeds" into the
168
+ // void instead of buffering (the frame is simply lost).
169
+ const wsOpen = this.ws && (this.ws.readyState === undefined || this.ws.readyState === 1);
170
+ if (this._state === 'open' && this.ws && wsOpen) {
171
+ try {
172
+ this.ws.send(text);
173
+ return true;
174
+ }
175
+ catch {
176
+ /* socket died under us — fall through to the buffer */
177
+ }
178
+ }
179
+ if (this.bufferSize <= 0)
180
+ return false;
181
+ this.buffer.push(text);
182
+ if (this.buffer.length > this.bufferSize) {
183
+ this.buffer.shift(); // drop-oldest
184
+ return false;
185
+ }
186
+ return true;
187
+ }
188
+ /** Ephemeral typing signal — never buffered (replaying a stale typing
189
+ * indicator after a reconnect would be wrong). Server-gated on presence. */
190
+ typing() {
191
+ this.sendEphemeral('{"type":"typing"}');
192
+ }
193
+ /** Ephemeral keepalive — never buffered. Server replies `pong` (swallowed). */
194
+ ping() {
195
+ this.sendEphemeral('{"type":"ping"}');
196
+ }
197
+ // ---- internals -----------------------------------------------------------
198
+ sendEphemeral(text) {
199
+ if (this._state !== 'open' || !this.ws)
200
+ return;
201
+ try {
202
+ this.ws.send(text);
203
+ }
204
+ catch {
205
+ /* closing — the close handler reconnects */
206
+ }
207
+ }
208
+ setState(state, detail) {
209
+ this._state = state;
210
+ for (const cb of [...this.stateListeners])
211
+ cb(state, detail);
212
+ }
213
+ settleOpen(error) {
214
+ const res = this.resolveOpen;
215
+ const rej = this.rejectOpen;
216
+ this.openPromise = null;
217
+ this.resolveOpen = null;
218
+ this.rejectOpen = null;
219
+ if (error !== undefined)
220
+ rej?.(error);
221
+ else
222
+ res?.();
223
+ }
224
+ /** Cached-or-fresh connect URL. Re-mints via the tokenProvider when the
225
+ * cached token is absent, has no expires_at, or is within expirySkewMs of
226
+ * expiry — which also covers the "server rejected the token" case: a
227
+ * browser only ever sees close 1006, and by the time that matters the
228
+ * token is expired or near-expiry and gets re-minted on the next attempt. */
229
+ async resolveUrl() {
230
+ const c = this.cached;
231
+ if (c && c.expiresAt !== null && c.expiresAt - Date.now() > this.expirySkewMs) {
232
+ return c.url;
233
+ }
234
+ const r = await this.opts.tokenProvider({ channel: this.name });
235
+ const url = this.urlFrom(r);
236
+ const parsed = r.expires_at ? Date.parse(r.expires_at) : NaN;
237
+ this.cached = { url, expiresAt: Number.isNaN(parsed) ? null : parsed };
238
+ return url;
239
+ }
240
+ urlFrom(r) {
241
+ if (r.connect_url)
242
+ return r.connect_url;
243
+ const base = (this.opts.baseUrl ?? DEFAULT_BASE).replace(/^http/, 'ws');
244
+ if (r.connect_path)
245
+ return `${base}${r.connect_path}`;
246
+ if (r.token)
247
+ return `${base}/v1/realtime/connect?token=${encodeURIComponent(r.token)}`;
248
+ throw new Error('[@vxil/realtime] tokenProvider returned none of connect_url / connect_path / token');
249
+ }
250
+ async connect() {
251
+ if (this.userClosed)
252
+ return;
253
+ this.setState('connecting');
254
+ let url;
255
+ try {
256
+ url = await this.resolveUrl();
257
+ }
258
+ catch (error) {
259
+ // tokenProvider failure counts as a failed attempt: back off and retry.
260
+ if (!this.userClosed)
261
+ this.scheduleReconnect(error);
262
+ return;
263
+ }
264
+ if (this.userClosed)
265
+ return;
266
+ let ws;
267
+ try {
268
+ ws = new this.WS(url);
269
+ }
270
+ catch (error) {
271
+ this.scheduleReconnect(error);
272
+ return;
273
+ }
274
+ this.ws = ws;
275
+ ws.addEventListener('open', () => {
276
+ if (this.ws !== ws || this.userClosed)
277
+ return;
278
+ this.handleOpen();
279
+ });
280
+ ws.addEventListener('message', (ev) => {
281
+ if (this.ws !== ws)
282
+ return;
283
+ this.handleMessage(typeof ev.data === 'string' ? ev.data : '');
284
+ });
285
+ ws.addEventListener('close', (ev) => {
286
+ if (this.ws !== ws || this.userClosed)
287
+ return;
288
+ this.ws = null;
289
+ // A pending deferred flush dies with its socket; the buffer is retained
290
+ // and re-armed on the next open.
291
+ this.awaitingFlush = false;
292
+ if (this.flushFallback !== undefined) {
293
+ clearTimeout(this.flushFallback);
294
+ this.flushFallback = undefined;
295
+ }
296
+ this.scheduleReconnect(new Error(`websocket closed (code ${ev.code ?? 'unknown'})`));
297
+ });
298
+ // In browsers every 'error' is followed by 'close' — counting both would
299
+ // double-step the backoff, so only 'close' drives the state machine.
300
+ ws.addEventListener('error', () => { });
301
+ }
302
+ handleOpen() {
303
+ this.attempt = 0;
304
+ this.everOpened = true;
305
+ this.setState('open');
306
+ // Flush the offline buffer on the server's presence.state seed, NOT in the
307
+ // open tick: the DO registers the socket's presence just after accept, and
308
+ // a frame that races that registration is dropped server-side (seen live
309
+ // on staging as a reconnect-flushed typing frame that never rebroadcast).
310
+ // The seed is pushed unconditionally on accept, so it doubles as the
311
+ // "server is ready for frames" signal; a short timer is the fallback in
312
+ // case a proxy eats it.
313
+ if (this.buffer.length > 0) {
314
+ this.awaitingFlush = true;
315
+ this.flushFallback = setTimeout(() => this.flushBuffer(), 500);
316
+ }
317
+ this.settleOpen();
318
+ }
319
+ /** Flush the offline buffer FIFO onto the current socket (idempotent). */
320
+ flushBuffer() {
321
+ if (!this.awaitingFlush)
322
+ return;
323
+ this.awaitingFlush = false;
324
+ if (this.flushFallback !== undefined) {
325
+ clearTimeout(this.flushFallback);
326
+ this.flushFallback = undefined;
327
+ }
328
+ // Socket died between open and flush: keep the buffer for the next open
329
+ // (handleOpen re-arms the deferred flush).
330
+ if (!this.ws || this._state !== 'open')
331
+ return;
332
+ const pending = this.buffer;
333
+ this.buffer = [];
334
+ for (const text of pending) {
335
+ try {
336
+ this.ws?.send(text);
337
+ }
338
+ catch {
339
+ break;
340
+ }
341
+ }
342
+ }
343
+ scheduleReconnect(error) {
344
+ this.attempt += 1;
345
+ if (this.attempt > this.maxAttempts) {
346
+ this.settleOpen(this.everOpened
347
+ ? undefined
348
+ : new Error(`[@vxil/realtime] gave up after ${this.maxAttempts} attempts`));
349
+ this.setState('closed', { attempt: this.attempt - 1, error });
350
+ return;
351
+ }
352
+ this.setState('reconnecting', { attempt: this.attempt, error });
353
+ const raw = Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** (this.attempt - 1));
354
+ const delay = raw * (0.5 + Math.random() * 0.5); // full-jitter-halved
355
+ this.timer = setTimeout(() => {
356
+ this.timer = null;
357
+ void this.connect();
358
+ }, delay);
359
+ }
360
+ handleMessage(text) {
361
+ // Any server frame proves the DO has fully registered this socket —
362
+ // release the deferred post-(re)connect buffer flush (see handleOpen).
363
+ if (this.awaitingFlush)
364
+ this.flushBuffer();
365
+ let frame;
366
+ try {
367
+ frame = JSON.parse(text);
368
+ }
369
+ catch {
370
+ return;
371
+ }
372
+ if (!frame || typeof frame.event !== 'string')
373
+ return;
374
+ if (frame.event === 'pong')
375
+ return; // internal keepalive reply, not re-emitted
376
+ if (frame.event.startsWith('presence.'))
377
+ this.handlePresence(frame);
378
+ this.dispatch(frame);
379
+ }
380
+ dispatch(frame) {
381
+ const exact = this.listeners.get(frame.event);
382
+ if (exact)
383
+ for (const cb of [...exact])
384
+ cb(frame);
385
+ const wild = this.listeners.get('*');
386
+ if (wild)
387
+ for (const cb of [...wild])
388
+ cb(frame);
389
+ }
390
+ emitPresence(ev, d) {
391
+ const set = this.presenceListeners.get(ev);
392
+ if (set)
393
+ for (const cb of [...set])
394
+ cb(d);
395
+ }
396
+ handlePresence(frame) {
397
+ const data = (frame.data ?? {});
398
+ if (frame.event === 'presence.state') {
399
+ // Authoritative roster seed (sent by the server on every accept):
400
+ // REPLACES whatever the client accumulated — the drift-bounding re-sync.
401
+ this.membersMap.clear();
402
+ for (const u of data.users ?? [])
403
+ this.membersMap.set(u.user_id, u.connections);
404
+ this.emitPresence('sync', { members: this.membersMap });
405
+ return;
406
+ }
407
+ const user = data.user_id;
408
+ if (frame.event === 'presence.join' && user) {
409
+ // The server de-dupes multi-tab joins (only a user's FIRST socket emits
410
+ // join), so first sight = 1 connection; presence.state re-syncs truth.
411
+ if (!this.membersMap.has(user))
412
+ this.membersMap.set(user, 1);
413
+ this.emitPresence('join', { user_id: user, members: this.membersMap });
414
+ }
415
+ else if (frame.event === 'presence.leave' && user) {
416
+ this.membersMap.delete(user);
417
+ this.emitPresence('leave', { user_id: user, members: this.membersMap });
418
+ }
419
+ else if (frame.event === 'presence.typing' && user) {
420
+ this.emitPresence('typing', { user_id: user });
421
+ }
422
+ }
423
+ }
424
+ export class RealtimeClient {
425
+ opts;
426
+ WS;
427
+ channels = new Map();
428
+ constructor(opts) {
429
+ this.opts = opts;
430
+ this.WS = resolveCtor(opts.WebSocket); // fail fast, actionably
431
+ }
432
+ /** Idempotent registry: the same name always returns the same handle. */
433
+ channel(name) {
434
+ let ch = this.channels.get(name);
435
+ if (!ch) {
436
+ ch = new Channel(name, this.opts, this.WS);
437
+ this.channels.set(name, ch);
438
+ }
439
+ return ch;
440
+ }
441
+ /** Unsubscribes every channel (deliberate close — no reconnects). */
442
+ close() {
443
+ for (const ch of this.channels.values())
444
+ ch.unsubscribe();
445
+ }
446
+ }
447
+ /** Convenience mirroring the served-file import path. */
448
+ export function createRealtimeClient(opts) {
449
+ return new RealtimeClient(opts);
450
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@vxil/realtime",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "description": "Reconnecting browser/Node WebSocket client for Vxil realtime channels (auto-reconnect, token refresh, offline buffer, presence helpers).",
8
+ "license": "MIT",
9
+ "homepage": "https://vxil.com",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/techmakerdev/vxil.git",
13
+ "directory": "packages/realtime"
14
+ },
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js",
21
+ "default": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "keywords": [
28
+ "vxil",
29
+ "realtime",
30
+ "websocket",
31
+ "presence",
32
+ "reconnect"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.build.json",
39
+ "typecheck": "tsc --noEmit"
40
+ }
41
+ }