@azuliani/node-service 0.0.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/Client/Client.js +246 -0
- package/Client/MonitoredSocket.js +61 -0
- package/Client/PullClient.js +28 -0
- package/Client/RPCClient.js +83 -0
- package/Client/SharedObjectClient.js +283 -0
- package/Client/SinkClient.js +21 -0
- package/Client/SourceClient.js +30 -0
- package/LICENSE +22 -0
- package/README.md +116 -0
- package/Service/PushService.js +29 -0
- package/Service/RPCService.js +39 -0
- package/Service/Service.js +179 -0
- package/Service/SharedObjectService.js +125 -0
- package/Service/SinkService.js +37 -0
- package/Service/SourceService.js +31 -0
- package/index.js +9 -0
- package/misc/Validation.js +275 -0
- package/package.json +36 -0
package/Client/Client.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const zmq = require("zeromq/v5-compat");
|
|
4
|
+
const MonitoredSocket = require("./MonitoredSocket");
|
|
5
|
+
|
|
6
|
+
const RPCClient = require("./RPCClient");
|
|
7
|
+
const SourceClient = require("./SourceClient");
|
|
8
|
+
const SharedObjectClient = require("./SharedObjectClient");
|
|
9
|
+
const PullClient = require("./PullClient");
|
|
10
|
+
const SinkClient = require("./SinkClient");
|
|
11
|
+
|
|
12
|
+
class Client {
|
|
13
|
+
constructor(descriptor, options = {}){
|
|
14
|
+
// options.initDelay - Delay in ms before SharedObjectClient calls _init() after subscribe().
|
|
15
|
+
// Allows time to receive queued diffs before fetching full state.
|
|
16
|
+
// Default: 100ms
|
|
17
|
+
this.descriptor = descriptor;
|
|
18
|
+
this.transports = {};
|
|
19
|
+
this._options = options;
|
|
20
|
+
|
|
21
|
+
// Timestamp-based heartbeat tracking
|
|
22
|
+
this._lastSourceMessageTime = null; // Updated on EVERY source message (O(1))
|
|
23
|
+
this._serverHeartbeatFrequencyMs = null; // Learned from first heartbeat
|
|
24
|
+
this._heartbeatCheckInterval = null; // Periodic check interval
|
|
25
|
+
this._isSourceConnected = false; // Track connection state
|
|
26
|
+
|
|
27
|
+
this.sourceDisconnections = {};
|
|
28
|
+
|
|
29
|
+
this._setupTransports();
|
|
30
|
+
this._setupEndpoints();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_setupTransports(){
|
|
34
|
+
for(let transport in this.descriptor.transports){
|
|
35
|
+
switch (transport){
|
|
36
|
+
case 'source':
|
|
37
|
+
this._setupSource(this.descriptor.transports.source.client);
|
|
38
|
+
break;
|
|
39
|
+
case 'sink':
|
|
40
|
+
this._setupSink(this.descriptor.transports.sink.client);
|
|
41
|
+
break;
|
|
42
|
+
case 'rpc':
|
|
43
|
+
this._setupRpc(this.descriptor.transports.rpc.client);
|
|
44
|
+
break;
|
|
45
|
+
case 'pushpull':
|
|
46
|
+
this._setupPull();
|
|
47
|
+
break;
|
|
48
|
+
default:
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_setupSource(hostname){
|
|
55
|
+
this._monitoredSource = new MonitoredSocket('sub');
|
|
56
|
+
this.transports.source = this._monitoredSource.sock;
|
|
57
|
+
|
|
58
|
+
this.transports.source.connect(hostname);
|
|
59
|
+
this._sourceHostname = hostname;
|
|
60
|
+
this.transports.source.on('message', this._sourceCallback.bind(this));
|
|
61
|
+
this._monitoredSource.on('disconnected', this._sourceClosed.bind(this));
|
|
62
|
+
this._monitoredSource.on('connected', this._sourceConnected.bind(this));
|
|
63
|
+
|
|
64
|
+
// Subscribe to heartbeat channel
|
|
65
|
+
this.transports.source.subscribe('_heartbeat');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_setupSink(hostname){
|
|
69
|
+
const sock = new zmq.socket('push');
|
|
70
|
+
this.transports.sink = sock;
|
|
71
|
+
sock.connect(hostname);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_sourceCallback(endpoint, message){
|
|
75
|
+
// O(1) timestamp update - MUST be first, before any parsing
|
|
76
|
+
this._lastSourceMessageTime = Date.now();
|
|
77
|
+
|
|
78
|
+
// Mark as connected when we receive messages (more reliable than ZMQ monitor)
|
|
79
|
+
if (!this._isSourceConnected) {
|
|
80
|
+
this._isSourceConnected = true;
|
|
81
|
+
// Emit connected events for endpoints
|
|
82
|
+
for (let ep of this.descriptor.endpoints) {
|
|
83
|
+
if (ep.type === 'Source' || ep.type === 'SharedObject') {
|
|
84
|
+
console.error(ep.name, 'connected');
|
|
85
|
+
this[ep.name].emit('connected');
|
|
86
|
+
// Re-init SharedObjects that were disconnected
|
|
87
|
+
if (ep.type === 'SharedObject' && this.sourceDisconnections[ep.name]) {
|
|
88
|
+
this[ep.name]._init();
|
|
89
|
+
delete this.sourceDisconnections[ep.name];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Handle heartbeat specially
|
|
96
|
+
if (endpoint.toString() === '_heartbeat') {
|
|
97
|
+
this._processHeartbeat(JSON.parse(message));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const data = JSON.parse(message);
|
|
102
|
+
this[endpoint]._processMessage(data);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_processHeartbeat(data) {
|
|
106
|
+
// Lazy activation: learn frequency from first heartbeat
|
|
107
|
+
if (this._serverHeartbeatFrequencyMs === null && data.frequencyMs) {
|
|
108
|
+
this._serverHeartbeatFrequencyMs = data.frequencyMs;
|
|
109
|
+
this._startHeartbeatChecking();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_startHeartbeatChecking() {
|
|
114
|
+
// Check once per heartbeat period
|
|
115
|
+
this._heartbeatCheckInterval = setInterval(() => {
|
|
116
|
+
this._checkHeartbeatTimeout();
|
|
117
|
+
}, this._serverHeartbeatFrequencyMs);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_checkHeartbeatTimeout() {
|
|
121
|
+
if (this._lastSourceMessageTime === null || !this._isSourceConnected) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const timeSinceLastMessage = Date.now() - this._lastSourceMessageTime;
|
|
126
|
+
const timeoutThreshold = this._serverHeartbeatFrequencyMs * 3;
|
|
127
|
+
|
|
128
|
+
if (timeSinceLastMessage > timeoutThreshold) {
|
|
129
|
+
this._heartbeatFailed();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
_sourceConnected(){
|
|
134
|
+
// Idempotent - may already be marked connected by _sourceCallback
|
|
135
|
+
if (this._isSourceConnected) return;
|
|
136
|
+
this._isSourceConnected = true;
|
|
137
|
+
this._lastSourceMessageTime = Date.now(); // Reset on reconnect
|
|
138
|
+
|
|
139
|
+
for(let endpoint of this.descriptor.endpoints) {
|
|
140
|
+
if (endpoint.type === 'Source' || endpoint.type === 'SharedObject') {
|
|
141
|
+
console.error(endpoint.name, 'connected');
|
|
142
|
+
this[endpoint.name].emit('connected');
|
|
143
|
+
if (endpoint.type === 'SharedObject' && this.sourceDisconnections[endpoint.name]) {
|
|
144
|
+
// _init() now guards against being called when not subscribed
|
|
145
|
+
this[endpoint.name]._init();
|
|
146
|
+
delete this.sourceDisconnections[endpoint.name];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_sourceClosed(){
|
|
153
|
+
// Idempotent - prevent double-firing
|
|
154
|
+
if (!this._isSourceConnected) return;
|
|
155
|
+
this._isSourceConnected = false;
|
|
156
|
+
|
|
157
|
+
for(let endpoint of this.descriptor.endpoints) {
|
|
158
|
+
if (endpoint.type === 'Source' || endpoint.type === 'SharedObject') {
|
|
159
|
+
console.error(endpoint.name, 'disconnected');
|
|
160
|
+
this[endpoint.name].emit('disconnected');
|
|
161
|
+
if (endpoint.type === 'SharedObject') {
|
|
162
|
+
this[endpoint.name]._emitDisconnectDiffs(); // BEFORE flush
|
|
163
|
+
this[endpoint.name]._flushData();
|
|
164
|
+
this.sourceDisconnections[endpoint.name] = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_heartbeatFailed(){
|
|
171
|
+
console.error('Heartbeat failed source transport -> Closing connection', this._sourceHostname, this.descriptor.endpoints.map((item)=>{return item.name}).join(','));
|
|
172
|
+
this.transports.source.disconnect(this._sourceHostname)
|
|
173
|
+
this._sourceClosed();
|
|
174
|
+
this.transports.source.connect(this._sourceHostname)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_setupRpc(origHostname) {
|
|
178
|
+
const hostnameAndPort = origHostname.split(":");
|
|
179
|
+
const hostname = hostnameAndPort[1].substr(2);
|
|
180
|
+
const port = hostnameAndPort[2];
|
|
181
|
+
this.transports.rpc = {hostname, port};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_setupPull(hostname){
|
|
185
|
+
const sock = new zmq.socket("pull");
|
|
186
|
+
// DON'T CONNECT! Client must explicitly ask!
|
|
187
|
+
sock.on('message', this._pullCallback.bind(this));
|
|
188
|
+
this.transports.pushpull = sock;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
_pullCallback(message){
|
|
192
|
+
if (!this.PullEndpoint){
|
|
193
|
+
throw new Error("Got a pull message, but ot Pull enpoint is connected!");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.PullEndpoint._processMessage(JSON.parse(message));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_setupEndpoints(){
|
|
200
|
+
for(let endpoint of this.descriptor.endpoints){
|
|
201
|
+
switch(endpoint.type){
|
|
202
|
+
case 'RPC':
|
|
203
|
+
this[endpoint.name] = new RPCClient(endpoint, this.transports);
|
|
204
|
+
break;
|
|
205
|
+
case 'Source':
|
|
206
|
+
this[endpoint.name] = new SourceClient(endpoint, this.transports);
|
|
207
|
+
break;
|
|
208
|
+
case 'SharedObject':
|
|
209
|
+
this[endpoint.name] = new SharedObjectClient(endpoint, this.transports, this._options);
|
|
210
|
+
this['_SO_'+endpoint.name] = this[endpoint.name];
|
|
211
|
+
break;
|
|
212
|
+
case 'PushPull':
|
|
213
|
+
if (this.PullEndpoint){
|
|
214
|
+
throw new Error("Only a singly Pushpull endpoint can be constructed per service!");
|
|
215
|
+
}
|
|
216
|
+
this[endpoint.name] = new PullClient(endpoint, this.transports, this.descriptor.transports.pushpull.client);
|
|
217
|
+
this.PullEndpoint = this[endpoint.name];
|
|
218
|
+
break;
|
|
219
|
+
case 'Sink':
|
|
220
|
+
this[endpoint.name] = new SinkClient(endpoint, this.transports, this.descriptor.transports.sink.client);
|
|
221
|
+
this.SinkEndpoint = this[endpoint.name];
|
|
222
|
+
break;
|
|
223
|
+
default:
|
|
224
|
+
throw "Unknown endpoint type.";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
close() {
|
|
230
|
+
if (this._heartbeatCheckInterval) {
|
|
231
|
+
clearInterval(this._heartbeatCheckInterval);
|
|
232
|
+
this._heartbeatCheckInterval = null;
|
|
233
|
+
}
|
|
234
|
+
if (this._monitoredSource) {
|
|
235
|
+
this._monitoredSource.close();
|
|
236
|
+
}
|
|
237
|
+
if (this.transports.sink) {
|
|
238
|
+
this.transports.sink.close();
|
|
239
|
+
}
|
|
240
|
+
if (this.transports.pushpull) {
|
|
241
|
+
this.transports.pushpull.close();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = Client;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const zmq = require("zeromq/v5-compat");
|
|
3
|
+
const EventEmitter = require("events").EventEmitter;
|
|
4
|
+
|
|
5
|
+
class MonitoredSocket extends EventEmitter {
|
|
6
|
+
constructor(type) {
|
|
7
|
+
super();
|
|
8
|
+
this.sock = new zmq.socket(type);
|
|
9
|
+
this.sock.on('monitor_error', this._handleError.bind(this));
|
|
10
|
+
this.sock.on('disconnect', this._handleDisconnect.bind(this));
|
|
11
|
+
this.sock.on('connect_retry', this._handleRetry.bind(this));
|
|
12
|
+
this.sock.on('connect', this._handleConnected.bind(this));
|
|
13
|
+
this._monitorSocket();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
_handleError(err) {
|
|
17
|
+
console.error('Error in monitoring: %s, will restart monitoring in 5 seconds', err);
|
|
18
|
+
setTimeout(this._monitorSocket.bind(this), 5000);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_handleDisconnect(fd, endpoint){
|
|
22
|
+
this.emit('disconnected');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_handleRetry(fd, endpoint){
|
|
26
|
+
this.emit('connect_retry');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_handleConnected(fd, endpoint){
|
|
30
|
+
this.emit('connected');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_monitorSocket(){
|
|
34
|
+
try {
|
|
35
|
+
// v5-compat ignores arguments - it reads all events automatically
|
|
36
|
+
this.sock.monitor();
|
|
37
|
+
} catch (err) {
|
|
38
|
+
// In test environments, monitor failures are non-fatal - the socket still works,
|
|
39
|
+
// we just won't get ZMQ-level disconnect events.
|
|
40
|
+
// The heartbeat system will still detect disconnects.
|
|
41
|
+
if (process.env.NODE_ENV === 'test' && err.code === 'EADDRINUSE') {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
close() {
|
|
49
|
+
try {
|
|
50
|
+
this.sock.unmonitor();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// In test environments, unmonitor failures are non-fatal during cleanup
|
|
53
|
+
if (!(process.env.NODE_ENV === 'test' && err.code === 'EADDRINUSE')) {
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
this.sock.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = MonitoredSocket;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require("events").EventEmitter;
|
|
4
|
+
|
|
5
|
+
class PullClient extends EventEmitter{
|
|
6
|
+
constructor(endpoint, transports, hostname){
|
|
7
|
+
super();
|
|
8
|
+
this.endpoint = endpoint;
|
|
9
|
+
this.transport = transports.pushpull;
|
|
10
|
+
this.hostname = hostname;
|
|
11
|
+
if (!this.transport)
|
|
12
|
+
throw new Error("Trying to construct Source endpoint without Source transport");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
subscribe(){
|
|
16
|
+
this.transport.connect(this.hostname);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
unsubscribe(){
|
|
20
|
+
this.transport.disconnect(this.hostname);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
_processMessage(data){
|
|
24
|
+
this.emit("message", data);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = PullClient;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const doValidation = require("../misc/Validation").RPCValidation;
|
|
5
|
+
|
|
6
|
+
class RPCClient {
|
|
7
|
+
constructor(endpoint, transports) {
|
|
8
|
+
this.transport = transports.rpc;
|
|
9
|
+
this.endpoint = endpoint;
|
|
10
|
+
if (!this.transport)
|
|
11
|
+
throw "Trying to initialise an RPC service without RPC config!";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
call(input, timeout, callback) {
|
|
15
|
+
const self = this;
|
|
16
|
+
if (!callback) { // Make compatible with old code
|
|
17
|
+
callback = timeout;
|
|
18
|
+
timeout = 10e3;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
doValidation(this.endpoint, 'input', input, false);
|
|
22
|
+
|
|
23
|
+
let answer_received = false;
|
|
24
|
+
let answer_timeout = setTimeout(() => {
|
|
25
|
+
if (!answer_received)
|
|
26
|
+
callback('timeout');
|
|
27
|
+
callback = null;
|
|
28
|
+
answer_received = null;
|
|
29
|
+
}, timeout);
|
|
30
|
+
const postData = JSON.stringify({
|
|
31
|
+
endpoint: this.endpoint.name,
|
|
32
|
+
input: input
|
|
33
|
+
});
|
|
34
|
+
const options = {
|
|
35
|
+
hostname: this.transport.hostname,
|
|
36
|
+
port: this.transport.port,
|
|
37
|
+
path: '/',
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: {
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
'Content-Length': Buffer.byteLength(postData)
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const req = http.request(options, (answer) => {
|
|
45
|
+
answer_received = true;
|
|
46
|
+
clearTimeout(answer_timeout);
|
|
47
|
+
answer_timeout = null;
|
|
48
|
+
|
|
49
|
+
let body = "";
|
|
50
|
+
answer.on('data', function (data) {
|
|
51
|
+
body += data;
|
|
52
|
+
});
|
|
53
|
+
answer.on('end', function () {
|
|
54
|
+
const answer = JSON.parse(body);
|
|
55
|
+
|
|
56
|
+
if (!answer.err) {
|
|
57
|
+
doValidation(self.endpoint, 'output', answer.res, true);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (callback) {
|
|
61
|
+
callback(answer.err, answer.res);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
req.on('error', (e) => {
|
|
67
|
+
console.error(`problem with request: ${e.message}`);
|
|
68
|
+
answer_received = true;
|
|
69
|
+
clearTimeout(answer_timeout);
|
|
70
|
+
answer_timeout = null;
|
|
71
|
+
|
|
72
|
+
if(callback) {
|
|
73
|
+
callback(e.message);
|
|
74
|
+
callback = null;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
req.write(postData);
|
|
78
|
+
req.end();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = RPCClient;
|
|
83
|
+
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require("events").EventEmitter;
|
|
4
|
+
const deepDiff = require("@azuliani/deep-diff");
|
|
5
|
+
const parseDiffDates = require("../misc/Validation").parseDiffDates;
|
|
6
|
+
const parseFullDates = require("../misc/Validation").parseFullDates;
|
|
7
|
+
|
|
8
|
+
const REPORTEVERY = 2000;
|
|
9
|
+
const OUTSTANDINGDIFFSTIMEOUT = 2000;
|
|
10
|
+
|
|
11
|
+
class SharedObjectClient extends EventEmitter {
|
|
12
|
+
constructor(endpoint, transports, options = {}) {
|
|
13
|
+
super();
|
|
14
|
+
if (!transports.rpc || !transports.source)
|
|
15
|
+
throw new Error("Shared object " + endpoint.name + " needs both Source and RPC transports to be configured");
|
|
16
|
+
|
|
17
|
+
this.endpoint = endpoint;
|
|
18
|
+
this.initTransport = transports.rpc;
|
|
19
|
+
this.updateTransport = transports.source;
|
|
20
|
+
// Delay before fetching full state after subscribe. ZMQ subscriptions take
|
|
21
|
+
// time to propagate to the server, so we wait to allow queued diffs to arrive
|
|
22
|
+
// first. If too short, we may miss early diffs and end up with version gaps.
|
|
23
|
+
this._initDelay = options.initDelay ?? 100;
|
|
24
|
+
this._fetchTimeoutMs = options.initTimeout ?? 3000;
|
|
25
|
+
|
|
26
|
+
// Connection and subscription state
|
|
27
|
+
this._connected = false;
|
|
28
|
+
this._subscribed = false;
|
|
29
|
+
this._initTimeout = null; // Tracks pending init/retry timeouts
|
|
30
|
+
this._initInFlight = false;
|
|
31
|
+
|
|
32
|
+
// Listen to events emitted by parent Client
|
|
33
|
+
this.on('connected', () => { this._connected = true; });
|
|
34
|
+
this.on('disconnected', () => { this._connected = false; });
|
|
35
|
+
|
|
36
|
+
// Initialize state
|
|
37
|
+
this.data = {};
|
|
38
|
+
this._v = 0;
|
|
39
|
+
this.firstChange = null;
|
|
40
|
+
this.lastChange = null;
|
|
41
|
+
this.outstandingDiffs = 0;
|
|
42
|
+
this.timeSum = 0;
|
|
43
|
+
this.timeCount = 0;
|
|
44
|
+
this.ready = false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get connected() {
|
|
48
|
+
return this._connected;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get subscribed() {
|
|
52
|
+
return this._subscribed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
subscribe() {
|
|
56
|
+
this._subscribed = true;
|
|
57
|
+
this.updateTransport.subscribe("_SO_" + this.endpoint.name);
|
|
58
|
+
this._initTimeout = setTimeout(() => { this._init() }, this._initDelay);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
unsubscribe() {
|
|
62
|
+
this._subscribed = false;
|
|
63
|
+
if (this._initTimeout) {
|
|
64
|
+
clearTimeout(this._initTimeout);
|
|
65
|
+
this._initTimeout = null;
|
|
66
|
+
}
|
|
67
|
+
this.updateTransport.unsubscribe("_SO_" + this.endpoint.name);
|
|
68
|
+
if (this.endpoint.slicedCache) {
|
|
69
|
+
this.endpoint.slicedCache.clear();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_processMessage(data) {
|
|
74
|
+
if (data.endpoint === "_SO_" + this.endpoint.name) {
|
|
75
|
+
// During init (!ready): queue ALL messages regardless of version.
|
|
76
|
+
// When init snapshot arrives, messages with v <= snapshot are discarded,
|
|
77
|
+
// and remaining messages are applied in order.
|
|
78
|
+
//
|
|
79
|
+
// After init (ready): require sequential versioning. Gaps trigger reinit.
|
|
80
|
+
|
|
81
|
+
if (this.ready) {
|
|
82
|
+
// Post-init: enforce sequential versioning
|
|
83
|
+
const expectedVersion = this.lastChange ? this.lastChange.v + 1 : this._v + 1;
|
|
84
|
+
|
|
85
|
+
// Skip stale messages (already processed, can arrive after re-init)
|
|
86
|
+
if (!this.lastChange && data.message.v <= this._v) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Version gap detected - reinit to recover
|
|
91
|
+
if (data.message.v !== expectedVersion) {
|
|
92
|
+
console.error(new Date(), "(" + this.endpoint.name + ") Out of order message! Expected v=" + expectedVersion + ", got v=" + data.message.v + ". Reinit.");
|
|
93
|
+
return this._init();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Link message into queue (always, during init; sequentially verified, after init)
|
|
98
|
+
if (!this.lastChange) {
|
|
99
|
+
this.firstChange = data.message;
|
|
100
|
+
} else {
|
|
101
|
+
this.lastChange.next = data.message;
|
|
102
|
+
}
|
|
103
|
+
this.lastChange = data.message;
|
|
104
|
+
|
|
105
|
+
this.outstandingDiffs++;
|
|
106
|
+
|
|
107
|
+
setImmediate(() => { this._tryApply() });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_tryApply() {
|
|
112
|
+
const totalDiffs = [];
|
|
113
|
+
const now = +(new Date());
|
|
114
|
+
|
|
115
|
+
if (!this.firstChange || !this.ready) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
let ptr = this.firstChange;
|
|
121
|
+
|
|
122
|
+
while (ptr) {
|
|
123
|
+
// Version gap in queued messages - can happen if initDelay was too short
|
|
124
|
+
// and we missed early diffs. Reinit to recover.
|
|
125
|
+
if (ptr.v !== this._v + 1) {
|
|
126
|
+
console.error(new Date(), "(" + this.endpoint.name + ") Version gap in queued message! Expected v=" + (this._v + 1) + ", got v=" + ptr.v + ". Reinit.");
|
|
127
|
+
return this._init();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Diffs are already reversed by Server!
|
|
131
|
+
let diffs = ptr.diffs;
|
|
132
|
+
safePush(totalDiffs, diffs)
|
|
133
|
+
|
|
134
|
+
for(let diff of diffs) {
|
|
135
|
+
parseDiffDates(this.endpoint, diff);
|
|
136
|
+
}
|
|
137
|
+
deepDiff.applyDiff(this.data, diffs);
|
|
138
|
+
|
|
139
|
+
this.timeSum += now - new Date(ptr.now);
|
|
140
|
+
this.timeCount++;
|
|
141
|
+
|
|
142
|
+
this._v++;
|
|
143
|
+
|
|
144
|
+
ptr = ptr.next;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this.firstChange = null;
|
|
148
|
+
this.lastChange = null;
|
|
149
|
+
this.outstandingDiffs = 0;
|
|
150
|
+
|
|
151
|
+
if (totalDiffs.length > 0) {
|
|
152
|
+
|
|
153
|
+
//setImmediate(() => { this.emit('update', totalDiffs); });
|
|
154
|
+
this.emit('update', totalDiffs);
|
|
155
|
+
|
|
156
|
+
if (this.timeCount > REPORTEVERY) {
|
|
157
|
+
console.error("(" + this.endpoint.name + ") Average time: " + (this.timeSum / this.timeCount) + " ms");
|
|
158
|
+
this.emit('timing', this.timeSum / this.timeCount);
|
|
159
|
+
this.timeSum = 0;
|
|
160
|
+
this.timeCount = 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (this.outstandingDiffsTimeout) {
|
|
164
|
+
console.error(new Date(), "(" + this.endpoint.name + ") Managed to process messages. Clearing the outstanding diffs timer.");
|
|
165
|
+
clearTimeout(this.outstandingDiffsTimeout);
|
|
166
|
+
delete this.outstandingDiffsTimeout;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
} else if (this.ready && this.outstandingDiffs > 10) {
|
|
170
|
+
if (!this.outstandingDiffsTimeout) {
|
|
171
|
+
console.error(new Date(), "(" + this.endpoint.name + ") Too many outstanding diffs. Starting the re-init timer.");
|
|
172
|
+
this.outstandingDiffsTimeout = setTimeout( () => {
|
|
173
|
+
console.error(new Date(), "(" + this.endpoint.name + ") Actually calling init now after outstanding diffs.");
|
|
174
|
+
delete this.outstandingDiffsTimeout;
|
|
175
|
+
this._init();
|
|
176
|
+
}, OUTSTANDINGDIFFSTIMEOUT);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
_prepareForInit() {
|
|
182
|
+
// Reset state but preserve message queue - it will be filtered
|
|
183
|
+
// when HTTP snapshot arrives (keeping only v > snapshot version)
|
|
184
|
+
this.data = {};
|
|
185
|
+
this._v = 0;
|
|
186
|
+
this.timeSum = 0;
|
|
187
|
+
this.timeCount = 0;
|
|
188
|
+
this.ready = false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
_emitDisconnectDiffs() {
|
|
192
|
+
// Generate synthetic diffs showing all properties deleted
|
|
193
|
+
const diffs = [];
|
|
194
|
+
for (const key of Object.keys(this.data)) {
|
|
195
|
+
diffs.push({
|
|
196
|
+
kind: 'D', // Deletion
|
|
197
|
+
path: [key],
|
|
198
|
+
lhs: this.data[key] // The value being deleted
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (diffs.length > 0) {
|
|
202
|
+
this.emit('update', diffs);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_flushData() {
|
|
207
|
+
// Reset all state on disconnect
|
|
208
|
+
this._prepareForInit();
|
|
209
|
+
// Also clear message queue (unlike _prepareForInit which preserves it)
|
|
210
|
+
this.firstChange = null;
|
|
211
|
+
this.lastChange = null;
|
|
212
|
+
this.outstandingDiffs = 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_init() {
|
|
216
|
+
if (!this._subscribed || this._initInFlight) return;
|
|
217
|
+
|
|
218
|
+
this._initInFlight = true;
|
|
219
|
+
this._prepareForInit();
|
|
220
|
+
|
|
221
|
+
fetch(`http://${this.initTransport.hostname}:${this.initTransport.port}/`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { 'Content-Type': 'application/json' },
|
|
224
|
+
body: JSON.stringify({
|
|
225
|
+
endpoint: `_SO_${this.endpoint.name}`,
|
|
226
|
+
input: "init"
|
|
227
|
+
}),
|
|
228
|
+
signal: AbortSignal.timeout(this._fetchTimeoutMs)
|
|
229
|
+
})
|
|
230
|
+
.then((res) => {
|
|
231
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
232
|
+
return res.json();
|
|
233
|
+
})
|
|
234
|
+
.then((answer) => {
|
|
235
|
+
if (!this._subscribed) return;
|
|
236
|
+
|
|
237
|
+
parseFullDates(this.endpoint, answer.res.data);
|
|
238
|
+
this.data = answer.res.data;
|
|
239
|
+
this._v = answer.res.v;
|
|
240
|
+
|
|
241
|
+
let ptr = this.firstChange;
|
|
242
|
+
let skipped = 0;
|
|
243
|
+
while (ptr && ptr.v <= answer.res.v) {
|
|
244
|
+
ptr = ptr.next;
|
|
245
|
+
skipped++;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
this.firstChange = ptr || null;
|
|
249
|
+
this.lastChange = null;
|
|
250
|
+
this.outstandingDiffs = 0;
|
|
251
|
+
|
|
252
|
+
while (ptr) {
|
|
253
|
+
this.outstandingDiffs++;
|
|
254
|
+
this.lastChange = ptr;
|
|
255
|
+
ptr = ptr.next;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
console.error(`${new Date()} (${this.endpoint.name}) Init installed version ${answer.res.v}. Skipped ${skipped} past changes. Have ${this.outstandingDiffs} outstanding changes.`);
|
|
259
|
+
|
|
260
|
+
this.ready = true;
|
|
261
|
+
this._tryApply();
|
|
262
|
+
this.emit('init', {v: answer.res.v, data: answer.res.data});
|
|
263
|
+
})
|
|
264
|
+
.catch((err) => {
|
|
265
|
+
console.error(`(${this.endpoint.name}) Init failed: ${err.message}`);
|
|
266
|
+
if (this._subscribed) {
|
|
267
|
+
this._initTimeout = setTimeout(() => this._init(), 1000);
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
.finally(() => {
|
|
271
|
+
this._initInFlight = false;
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function safePush(to, push) {
|
|
277
|
+
let startIndex = to.length;
|
|
278
|
+
for(let i = 0; i<push.length; i++) {
|
|
279
|
+
to[startIndex+i] = push[i];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
module.exports = SharedObjectClient;
|