reachlo 1.2.1 → 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 +209 -24
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -6,13 +6,58 @@ class Reachlo {
6
6
  this.socket = null;
7
7
  this.channels = new Map();
8
8
  this.pendingSubs = new Set();
9
+ this.offlineBuffer = []; // Buffer for publish events when disconnected
9
10
 
10
- // Resilience state
11
- this.heartbeatInterval = options.heartbeatInterval || 20000; // 20s default
12
- this.reconnectInterval = options.reconnectInterval || 3000; // 3s default
11
+ // Options
12
+ this.bufferPublish = options.bufferPublish ?? false; // Drop by default for AI
13
+ this.heartbeatInterval = options.heartbeatInterval || 20000;
14
+ this.reconnectInterval = options.reconnectInterval || 3000;
15
+
16
+ // State
13
17
  this.heartbeatTimer = null;
14
18
  this.reconnectTimer = null;
15
19
  this.shouldReconnect = true;
20
+ this.lastPong = Date.now();
21
+ this.reconnecting = false; // Prevent duplicate events
22
+
23
+ // Protocol v2 State
24
+ this.msgId = 0;
25
+ this.ackPending = new Map(); // id -> { resolve, reject, timeout }
26
+
27
+ // Lifecycle Listeners
28
+ this.listeners = {
29
+ connect: new Set(),
30
+ disconnect: new Set(),
31
+ reconnecting: new Set(),
32
+ error: new Set()
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Register lifecycle event listener
38
+ * @param {string} event - 'connect' | 'disconnect' | 'reconnecting'
39
+ * @param {function} cb
40
+ */
41
+ on(event, cb) {
42
+ if (this.listeners[event]) {
43
+ this.listeners[event].add(cb);
44
+ }
45
+ return () => this.off(event, cb);
46
+ }
47
+
48
+ off(event, cb) {
49
+ if (this.listeners[event]) {
50
+ this.listeners[event].delete(cb);
51
+ }
52
+ }
53
+
54
+ _emitLifecycle(event) {
55
+ if (this.listeners[event]) {
56
+ this.listeners[event].forEach(cb => {
57
+ // Wrap in microtasks to prevent blocking
58
+ queueMicrotask(() => cb());
59
+ });
60
+ }
16
61
  }
17
62
 
18
63
  connect() {
@@ -20,26 +65,63 @@ class Reachlo {
20
65
  return new Promise((resolve, reject) => {
21
66
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
22
67
 
23
- const urlWithAuth = `${this.url}?api_key=${this.apiKey}`;
24
- this.socket = new WebSocket(urlWithAuth);
68
+ // Auth via Subprotocol (Preferred)
69
+ // Fallback to query param if needed (can be toggled via options if we want strict mode)
70
+ // For v1.1 we try protocol first.
71
+ let protocols = ['reachlo', this.apiKey];
72
+
73
+ // Note: Some browsers/proxies dislike spaces in protocols.
74
+ // We pass array to WebSocket constructor which handles formatting.
75
+ this.socket = new WebSocket(this.url, protocols);
25
76
 
26
77
  this.socket.onopen = () => {
27
78
  console.log("Reachlo: Connected");
79
+ this.reconnecting = false;
80
+ this.lastPong = Date.now();
28
81
  this._startHeartbeat();
82
+ this._emitLifecycle('connect');
29
83
 
30
84
  // Resubscribe active channels + flush pending
31
85
  const toSubscribe = new Set([...this.channels.keys(), ...this.pendingSubs]);
32
- toSubscribe.forEach(channel =>
33
- this._send({ type: 'subscribe', channel })
34
- );
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
+ });
35
95
  this.pendingSubs.clear();
96
+
97
+ // Flush Offline Buffer (if enabled)
98
+ if (this.bufferPublish && this.offlineBuffer.length > 0) {
99
+ const toFlush = [...this.offlineBuffer];
100
+ this.offlineBuffer = [];
101
+ toFlush.forEach(msg => this._send(msg));
102
+ }
103
+
36
104
  resolve();
37
105
  };
38
106
 
39
107
  this.socket.onclose = () => {
40
108
  console.log("Reachlo: Connection closed");
41
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
+
118
+ this._emitLifecycle('disconnect');
119
+
42
120
  if (this.shouldReconnect) {
121
+ if (!this.reconnecting) {
122
+ this.reconnecting = true;
123
+ this._emitLifecycle('reconnecting');
124
+ }
43
125
  console.log(`Reachlo: Retrying in ${this.reconnectInterval / 1000}s...`);
44
126
  this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectInterval);
45
127
  }
@@ -47,17 +129,38 @@ class Reachlo {
47
129
 
48
130
  this.socket.onerror = (err) => {
49
131
  console.error("Reachlo: Socket error", err);
50
- // Reject only if first attempt, otherwise let onclose handle reconnect
51
- if (this.socket.readyState !== 1) reject(err);
132
+ if (this.socket.readyState !== 1 && !this.reconnecting) reject(err);
52
133
  };
53
134
 
54
135
  this.socket.onmessage = (event) => {
55
136
  try {
56
137
  const payload = JSON.parse(event.data);
57
- if (payload.type === 'pong') return; // Heartbeat response
138
+ if (payload.type === 'pong') {
139
+ this.lastPong = Date.now();
140
+ return;
141
+ }
58
142
 
59
- if (payload.channel && this.channels.has(payload.channel)) {
60
- 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
+ }
61
164
  }
62
165
  } catch (e) {
63
166
  console.error("Reachlo: Message parse error", e);
@@ -69,7 +172,14 @@ class Reachlo {
69
172
  _startHeartbeat() {
70
173
  this._stopHeartbeat();
71
174
  this.heartbeatTimer = setInterval(() => {
175
+ // Heartbeat Logic: Send Ping
72
176
  this._send({ type: 'ping' });
177
+
178
+ // Detect Half-Open: If Pong expired
179
+ if (Date.now() - this.lastPong > (this.heartbeatInterval * 2)) {
180
+ console.warn("Reachlo: Heartbeat timeout. Simulating disconnect.");
181
+ if (this.socket) this.socket.close(); // Triggers onclose -> reconnect
182
+ }
73
183
  }, this.heartbeatInterval);
74
184
  }
75
185
 
@@ -83,22 +193,22 @@ class Reachlo {
83
193
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
84
194
  this._stopHeartbeat();
85
195
 
196
+
86
197
  if (this.socket) {
87
198
  this.socket.close();
88
199
  this.socket = null;
89
200
  }
90
- // NOTE: We keep this.channels so we can resubscribe on manual reconnect
91
- // but we clear pendingSubs because we're explicitly stopping.
92
201
  this.pendingSubs.clear();
202
+ this.offlineBuffer = [];
93
203
  }
94
204
 
95
- channel(channelName) {
205
+ channel(channelName, options = {}) {
96
206
  if (!this.channels.has(channelName)) {
97
- const channel = new ReachloChannel(channelName, this);
207
+ const channel = new ReachloChannel(channelName, this, options);
98
208
  this.channels.set(channelName, channel);
99
209
 
100
210
  if (this.socket?.readyState === 1) { // WebSocket.OPEN is 1
101
- this._send({ type: 'subscribe', channel: channelName });
211
+ this._send({ type: 'subscribe', channel: channelName, options: options });
102
212
  } else {
103
213
  this.pendingSubs.add(channelName);
104
214
  }
@@ -108,17 +218,30 @@ class Reachlo {
108
218
  }
109
219
 
110
220
  _send(payload) {
111
- if (this.socket && this.socket.readyState === 1) { // WebSocket.OPEN
221
+ if (this.socket && this.socket.readyState === 1) {
112
222
  this.socket.send(JSON.stringify(payload));
223
+ return true;
224
+ } else {
225
+ if (this.bufferPublish && payload.type === 'publish') {
226
+ this.offlineBuffer.push(payload);
227
+ return true; // Queued
228
+ }
229
+ return false; // Dropped
113
230
  }
114
231
  }
115
232
  }
116
233
 
117
234
  class ReachloChannel {
118
- constructor(name, client) {
235
+ constructor(name, client, options = {}) {
119
236
  this.name = name;
120
237
  this.client = client;
238
+ this.options = options; // { type: 'durable' | 'presence' | 'stream' }
121
239
  this.callbacks = new Set();
240
+ this.eventListeners = {
241
+ join: new Set(),
242
+ leave: new Set(),
243
+ backpressure: new Set()
244
+ };
122
245
  }
123
246
 
124
247
  subscribe(cb) {
@@ -126,13 +249,75 @@ class ReachloChannel {
126
249
  return () => this.callbacks.delete(cb); // unsubscribe handle
127
250
  }
128
251
 
129
- publish(data) {
130
- 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
+ });
131
306
  }
132
307
 
133
308
  _emit(data) {
134
- // Sends the raw data to all registered listeners for this channel
135
- this.callbacks.forEach(cb => cb(data));
309
+ // Microtask Delivery to prevent Event Loop Blocking
310
+ this.callbacks.forEach(cb => {
311
+ queueMicrotask(() => cb(data));
312
+ });
313
+ }
314
+
315
+ _emitEvent(event, payload) {
316
+ if (this.eventListeners[event]) {
317
+ this.eventListeners[event].forEach(cb => {
318
+ queueMicrotask(() => cb(payload));
319
+ });
320
+ }
136
321
  }
137
322
  }
138
323
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "reachlo",
3
- "version": "1.2.1",
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",