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.
- package/index.js +136 -19
- 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
|
-
|
|
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(
|
|
82
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
125
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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));
|
|
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.
|
|
4
|
-
"description": "
|
|
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",
|