reachlo 1.2.2 → 1.3.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.
Files changed (2) hide show
  1. package/index.js +118 -14
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -20,11 +20,16 @@ class Reachlo {
20
20
  this.lastPong = Date.now();
21
21
  this.reconnecting = false; // Prevent duplicate events
22
22
 
23
+ // Protocol v2 State
24
+ this.msgId = 0;
25
+ this.ackPending = new Map(); // id -> { resolve, reject, timeout }
26
+
23
27
  // Lifecycle Listeners
24
28
  this.listeners = {
25
29
  connect: new Set(),
26
30
  disconnect: new Set(),
27
- reconnecting: new Set()
31
+ reconnecting: new Set(),
32
+ error: new Set()
28
33
  };
29
34
  }
30
35
 
@@ -78,9 +83,15 @@ class Reachlo {
78
83
 
79
84
  // Resubscribe active channels + flush pending
80
85
  const toSubscribe = new Set([...this.channels.keys(), ...this.pendingSubs]);
81
- toSubscribe.forEach(channel =>
82
- this._send({ type: 'subscribe', channel })
83
- );
86
+ toSubscribe.forEach(channelName => {
87
+ const channel = this.channels.get(channelName);
88
+ // Pass capabilities if available (if it was already created)
89
+ // If it was just pendingSub (no channel obj yet? Wait, pendingSub is only added if channel obj created)
90
+ // Yes, logic: create Channel obj -> if socket open send, else add to pending.
91
+ // So channel obj always exists when using pendingSubs for resubscribe
92
+ const options = channel ? channel.options : {};
93
+ this._send({ type: 'subscribe', channel: channelName, options: options });
94
+ });
84
95
  this.pendingSubs.clear();
85
96
 
86
97
  // Flush Offline Buffer (if enabled)
@@ -96,6 +107,14 @@ class Reachlo {
96
107
  this.socket.onclose = () => {
97
108
  console.log("Reachlo: Connection closed");
98
109
  this._stopHeartbeat();
110
+
111
+ // SDK Review Fix: ACK Memory Leak Protection
112
+ this.ackPending.forEach(({ reject, timeout }) => {
113
+ clearTimeout(timeout);
114
+ reject(new Error('Disconnected'));
115
+ });
116
+ this.ackPending.clear();
117
+
99
118
  this._emitLifecycle('disconnect');
100
119
 
101
120
  if (this.shouldReconnect) {
@@ -121,8 +140,27 @@ class Reachlo {
121
140
  return;
122
141
  }
123
142
 
124
- if (payload.channel && this.channels.has(payload.channel)) {
125
- this.channels.get(payload.channel)._emit(payload.data);
143
+ if (payload.type === 'ack') {
144
+ const pending = this.ackPending.get(payload.id);
145
+ if (pending) {
146
+ pending.resolve();
147
+ clearTimeout(pending.timeout);
148
+ this.ackPending.delete(payload.id);
149
+ }
150
+ return;
151
+ }
152
+
153
+ const channelObj = this.channels.get(payload.channel);
154
+ if (channelObj) {
155
+ if (payload.type === 'publish') {
156
+ channelObj._emit(payload.data);
157
+ } else if (payload.type === 'backpressure') {
158
+ channelObj._emitEvent('backpressure', payload);
159
+ } else if (payload.type === 'join') {
160
+ channelObj._emitEvent('join', payload);
161
+ } else if (payload.type === 'leave') {
162
+ channelObj._emitEvent('leave', payload);
163
+ }
126
164
  }
127
165
  } catch (e) {
128
166
  console.error("Reachlo: Message parse error", e);
@@ -154,7 +192,7 @@ class Reachlo {
154
192
  this.shouldReconnect = false;
155
193
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
156
194
  this._stopHeartbeat();
157
- this._emitLifecycle('disconnect');
195
+
158
196
 
159
197
  if (this.socket) {
160
198
  this.socket.close();
@@ -164,13 +202,13 @@ class Reachlo {
164
202
  this.offlineBuffer = [];
165
203
  }
166
204
 
167
- channel(channelName) {
205
+ channel(channelName, options = {}) {
168
206
  if (!this.channels.has(channelName)) {
169
- const channel = new ReachloChannel(channelName, this);
207
+ const channel = new ReachloChannel(channelName, this, options);
170
208
  this.channels.set(channelName, channel);
171
209
 
172
210
  if (this.socket?.readyState === 1) { // WebSocket.OPEN is 1
173
- this._send({ type: 'subscribe', channel: channelName });
211
+ this._send({ type: 'subscribe', channel: channelName, options: options });
174
212
  } else {
175
213
  this.pendingSubs.add(channelName);
176
214
  }
@@ -194,10 +232,16 @@ class Reachlo {
194
232
  }
195
233
 
196
234
  class ReachloChannel {
197
- constructor(name, client) {
235
+ constructor(name, client, options = {}) {
198
236
  this.name = name;
199
237
  this.client = client;
238
+ this.options = options; // { type: 'durable' | 'presence' | 'stream' }
200
239
  this.callbacks = new Set();
240
+ this.eventListeners = {
241
+ join: new Set(),
242
+ leave: new Set(),
243
+ backpressure: new Set()
244
+ };
201
245
  }
202
246
 
203
247
  subscribe(cb) {
@@ -205,16 +249,76 @@ class ReachloChannel {
205
249
  return () => this.callbacks.delete(cb); // unsubscribe handle
206
250
  }
207
251
 
208
- publish(data) {
209
- return this.client._send({ type: 'publish', channel: this.name, data });
252
+ on(event, cb) {
253
+ if (this.eventListeners[event]) {
254
+ this.eventListeners[event].add(cb);
255
+ }
256
+ return () => this.off(event, cb);
257
+ }
258
+
259
+ off(event, cb) {
260
+ if (this.eventListeners[event]) {
261
+ this.eventListeners[event].delete(cb);
262
+ }
263
+ }
264
+
265
+ publish(data, options = {}) {
266
+ const payload = {
267
+ type: 'publish',
268
+ channel: this.name,
269
+ data
270
+ };
271
+
272
+ if (options.delivery === 'ack') {
273
+ payload.delivery = 'ack';
274
+ const id = ++this.client.msgId;
275
+ payload.id = id;
276
+
277
+ return new Promise((resolve, reject) => {
278
+ const timeoutMs = options.timeout || 5000;
279
+ const timeout = setTimeout(() => {
280
+ this.client.ackPending.delete(id);
281
+ reject(new Error('ACK timeout'));
282
+ }, timeoutMs);
283
+
284
+ this.client.ackPending.set(id, { resolve, reject, timeout });
285
+
286
+ const sentOrQueued = this.client._send(payload);
287
+ if (!sentOrQueued) {
288
+ clearTimeout(timeout);
289
+ this.client.ackPending.delete(id);
290
+ reject(new Error('Socket not connected'));
291
+ }
292
+ });
293
+ }
294
+
295
+ // Fire-and-forget
296
+ this.client._send(payload);
297
+ return Promise.resolve();
298
+ }
299
+
300
+ sync(lastSeenId = 0) {
301
+ this.client._send({
302
+ type: 'sync',
303
+ channel: this.name,
304
+ lastSeenId
305
+ });
210
306
  }
211
307
 
212
308
  _emit(data) {
213
309
  // Microtask Delivery to prevent Event Loop Blocking
214
310
  this.callbacks.forEach(cb => {
215
- queueMicrotask(() => cb(data)); // <-- The Fix
311
+ queueMicrotask(() => cb(data));
216
312
  });
217
313
  }
314
+
315
+ _emitEvent(event, payload) {
316
+ if (this.eventListeners[event]) {
317
+ this.eventListeners[event].forEach(cb => {
318
+ queueMicrotask(() => cb(payload));
319
+ });
320
+ }
321
+ }
218
322
  }
219
323
 
220
324
  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.0",
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",