reachlo 1.2.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +136 -19
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -9,6 +9,7 @@ class Reachlo {
9
9
  this.offlineBuffer = []; // Buffer for publish events when disconnected
10
10
 
11
11
  // Options
12
+ this.debug = options.debug || false; // Default: false (silent)
12
13
  this.bufferPublish = options.bufferPublish ?? false; // Drop by default for AI
13
14
  this.heartbeatInterval = options.heartbeatInterval || 20000;
14
15
  this.reconnectInterval = options.reconnectInterval || 3000;
@@ -20,11 +21,16 @@ class Reachlo {
20
21
  this.lastPong = Date.now();
21
22
  this.reconnecting = false; // Prevent duplicate events
22
23
 
24
+ // Protocol v2 State
25
+ this.msgId = 0;
26
+ this.ackPending = new Map(); // id -> { resolve, reject, timeout }
27
+
23
28
  // Lifecycle Listeners
24
29
  this.listeners = {
25
30
  connect: new Set(),
26
31
  disconnect: new Set(),
27
- reconnecting: new Set()
32
+ reconnecting: new Set(),
33
+ error: new Set()
28
34
  };
29
35
  }
30
36
 
@@ -55,6 +61,18 @@ class Reachlo {
55
61
  }
56
62
  }
57
63
 
64
+ _log(...args) {
65
+ if (this.debug) console.log(...args);
66
+ }
67
+
68
+ _warn(...args) {
69
+ if (this.debug) console.warn(...args);
70
+ }
71
+
72
+ _error(...args) {
73
+ if (this.debug) console.error(...args);
74
+ }
75
+
58
76
  connect() {
59
77
  this.shouldReconnect = true;
60
78
  return new Promise((resolve, reject) => {
@@ -70,7 +88,7 @@ class Reachlo {
70
88
  this.socket = new WebSocket(this.url, protocols);
71
89
 
72
90
  this.socket.onopen = () => {
73
- console.log("Reachlo: Connected");
91
+ this._log("Reachlo: Connected");
74
92
  this.reconnecting = false;
75
93
  this.lastPong = Date.now();
76
94
  this._startHeartbeat();
@@ -78,9 +96,15 @@ class Reachlo {
78
96
 
79
97
  // Resubscribe active channels + flush pending
80
98
  const toSubscribe = new Set([...this.channels.keys(), ...this.pendingSubs]);
81
- toSubscribe.forEach(channel =>
82
- this._send({ type: 'subscribe', channel })
83
- );
99
+ toSubscribe.forEach(channelName => {
100
+ const channel = this.channels.get(channelName);
101
+ // Pass capabilities if available (if it was already created)
102
+ // If it was just pendingSub (no channel obj yet? Wait, pendingSub is only added if channel obj created)
103
+ // Yes, logic: create Channel obj -> if socket open send, else add to pending.
104
+ // So channel obj always exists when using pendingSubs for resubscribe
105
+ const options = channel ? channel.options : {};
106
+ this._send({ type: 'subscribe', channel: channelName, options: options });
107
+ });
84
108
  this.pendingSubs.clear();
85
109
 
86
110
  // Flush Offline Buffer (if enabled)
@@ -94,8 +118,16 @@ class Reachlo {
94
118
  };
95
119
 
96
120
  this.socket.onclose = () => {
97
- console.log("Reachlo: Connection closed");
121
+ this._log("Reachlo: Connection closed");
98
122
  this._stopHeartbeat();
123
+
124
+ // SDK Review Fix: ACK Memory Leak Protection
125
+ this.ackPending.forEach(({ reject, timeout }) => {
126
+ clearTimeout(timeout);
127
+ reject(new Error('Disconnected'));
128
+ });
129
+ this.ackPending.clear();
130
+
99
131
  this._emitLifecycle('disconnect');
100
132
 
101
133
  if (this.shouldReconnect) {
@@ -109,7 +141,7 @@ class Reachlo {
109
141
  };
110
142
 
111
143
  this.socket.onerror = (err) => {
112
- console.error("Reachlo: Socket error", err);
144
+ this._error("Reachlo: Socket error", err);
113
145
  if (this.socket.readyState !== 1 && !this.reconnecting) reject(err);
114
146
  };
115
147
 
@@ -121,11 +153,30 @@ class Reachlo {
121
153
  return;
122
154
  }
123
155
 
124
- if (payload.channel && this.channels.has(payload.channel)) {
125
- this.channels.get(payload.channel)._emit(payload.data);
156
+ if (payload.type === 'ack') {
157
+ const pending = this.ackPending.get(payload.id);
158
+ if (pending) {
159
+ pending.resolve();
160
+ clearTimeout(pending.timeout);
161
+ this.ackPending.delete(payload.id);
162
+ }
163
+ return;
164
+ }
165
+
166
+ const channelObj = this.channels.get(payload.channel);
167
+ if (channelObj) {
168
+ if (payload.type === 'publish') {
169
+ channelObj._emit(payload.data);
170
+ } else if (payload.type === 'backpressure') {
171
+ channelObj._emitEvent('backpressure', payload);
172
+ } else if (payload.type === 'join') {
173
+ channelObj._emitEvent('join', payload);
174
+ } else if (payload.type === 'leave') {
175
+ channelObj._emitEvent('leave', payload);
176
+ }
126
177
  }
127
178
  } catch (e) {
128
- console.error("Reachlo: Message parse error", e);
179
+ this._error("Reachlo: Message parse error", e);
129
180
  }
130
181
  };
131
182
  });
@@ -139,7 +190,7 @@ class Reachlo {
139
190
 
140
191
  // Detect Half-Open: If Pong expired
141
192
  if (Date.now() - this.lastPong > (this.heartbeatInterval * 2)) {
142
- console.warn("Reachlo: Heartbeat timeout. Simulating disconnect.");
193
+ this._warn("Reachlo: Heartbeat timeout. Simulating disconnect.");
143
194
  if (this.socket) this.socket.close(); // Triggers onclose -> reconnect
144
195
  }
145
196
  }, this.heartbeatInterval);
@@ -154,7 +205,7 @@ class Reachlo {
154
205
  this.shouldReconnect = false;
155
206
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
156
207
  this._stopHeartbeat();
157
- this._emitLifecycle('disconnect');
208
+
158
209
 
159
210
  if (this.socket) {
160
211
  this.socket.close();
@@ -164,13 +215,13 @@ class Reachlo {
164
215
  this.offlineBuffer = [];
165
216
  }
166
217
 
167
- channel(channelName) {
218
+ channel(channelName, options = {}) {
168
219
  if (!this.channels.has(channelName)) {
169
- const channel = new ReachloChannel(channelName, this);
220
+ const channel = new ReachloChannel(channelName, this, options);
170
221
  this.channels.set(channelName, channel);
171
222
 
172
223
  if (this.socket?.readyState === 1) { // WebSocket.OPEN is 1
173
- this._send({ type: 'subscribe', channel: channelName });
224
+ this._send({ type: 'subscribe', channel: channelName, options: options });
174
225
  } else {
175
226
  this.pendingSubs.add(channelName);
176
227
  }
@@ -194,10 +245,16 @@ class Reachlo {
194
245
  }
195
246
 
196
247
  class ReachloChannel {
197
- constructor(name, client) {
248
+ constructor(name, client, options = {}) {
198
249
  this.name = name;
199
250
  this.client = client;
251
+ this.options = options; // { type: 'durable' | 'presence' | 'stream' }
200
252
  this.callbacks = new Set();
253
+ this.eventListeners = {
254
+ join: new Set(),
255
+ leave: new Set(),
256
+ backpressure: new Set()
257
+ };
201
258
  }
202
259
 
203
260
  subscribe(cb) {
@@ -205,16 +262,76 @@ class ReachloChannel {
205
262
  return () => this.callbacks.delete(cb); // unsubscribe handle
206
263
  }
207
264
 
208
- publish(data) {
209
- return this.client._send({ type: 'publish', channel: this.name, data });
265
+ on(event, cb) {
266
+ if (this.eventListeners[event]) {
267
+ this.eventListeners[event].add(cb);
268
+ }
269
+ return () => this.off(event, cb);
270
+ }
271
+
272
+ off(event, cb) {
273
+ if (this.eventListeners[event]) {
274
+ this.eventListeners[event].delete(cb);
275
+ }
276
+ }
277
+
278
+ publish(data, options = {}) {
279
+ const payload = {
280
+ type: 'publish',
281
+ channel: this.name,
282
+ data
283
+ };
284
+
285
+ if (options.delivery === 'ack') {
286
+ payload.delivery = 'ack';
287
+ const id = ++this.client.msgId;
288
+ payload.id = id;
289
+
290
+ return new Promise((resolve, reject) => {
291
+ const timeoutMs = options.timeout || 5000;
292
+ const timeout = setTimeout(() => {
293
+ this.client.ackPending.delete(id);
294
+ reject(new Error('ACK timeout'));
295
+ }, timeoutMs);
296
+
297
+ this.client.ackPending.set(id, { resolve, reject, timeout });
298
+
299
+ const sentOrQueued = this.client._send(payload);
300
+ if (!sentOrQueued) {
301
+ clearTimeout(timeout);
302
+ this.client.ackPending.delete(id);
303
+ reject(new Error('Socket not connected'));
304
+ }
305
+ });
306
+ }
307
+
308
+ // Fire-and-forget
309
+ this.client._send(payload);
310
+ return Promise.resolve();
311
+ }
312
+
313
+ sync(lastSeenId = 0) {
314
+ this.client._send({
315
+ type: 'sync',
316
+ channel: this.name,
317
+ lastSeenId
318
+ });
210
319
  }
211
320
 
212
321
  _emit(data) {
213
322
  // Microtask Delivery to prevent Event Loop Blocking
214
323
  this.callbacks.forEach(cb => {
215
- queueMicrotask(() => cb(data)); // <-- The Fix
324
+ queueMicrotask(() => cb(data));
216
325
  });
217
326
  }
327
+
328
+ _emitEvent(event, payload) {
329
+ if (this.eventListeners[event]) {
330
+ this.eventListeners[event].forEach(cb => {
331
+ queueMicrotask(() => cb(payload));
332
+ });
333
+ }
334
+ }
218
335
  }
219
336
 
220
337
  export default Reachlo;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "reachlo",
3
- "version": "1.2.2",
4
- "description": "Real-time infrastructure for AI streaming.",
3
+ "version": "1.3.1",
4
+ "description": "Streaming-native real-time infrastructure. Ordered channels, durable replay, presence, backpressure, and ACK delivery.",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
7
  "author": "Reachlo Team",