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.
- package/index.js +209 -24
- 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
|
-
//
|
|
11
|
-
this.
|
|
12
|
-
this.
|
|
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
|
-
|
|
24
|
-
|
|
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(
|
|
33
|
-
this.
|
|
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
|
-
|
|
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')
|
|
138
|
+
if (payload.type === 'pong') {
|
|
139
|
+
this.lastPong = Date.now();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
58
142
|
|
|
59
|
-
if (payload.
|
|
60
|
-
this.
|
|
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) {
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
//
|
|
135
|
-
this.callbacks.forEach(cb =>
|
|
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.
|
|
4
|
-
"description": "
|
|
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",
|