jsgar 4.0.1 → 4.0.2
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/gar.js +1466 -0
- package/package.json +11 -2
package/gar.js
ADDED
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A client implementation for the Generic Active Records (GAR) protocol using WebSockets.
|
|
3
|
+
* See trsgar.js for example usage.
|
|
4
|
+
*
|
|
5
|
+
* The GARClient class provides a JavaScript interface for connecting to a GAR server
|
|
6
|
+
* using WebSockets as the transport layer. It handles protocol details including
|
|
7
|
+
* message serialization, heartbeat management, topic and key enumeration, record
|
|
8
|
+
* updates, and subscription management.
|
|
9
|
+
*
|
|
10
|
+
* Key features:
|
|
11
|
+
* - Automatic heartbeat management to maintain connection
|
|
12
|
+
* - Support for topic and key int <-> string introductions
|
|
13
|
+
* - Record creation, updating, and deletion
|
|
14
|
+
* - Subscription management with filtering options
|
|
15
|
+
* - Customizable message handlers for all protocol message types
|
|
16
|
+
* - Automatic reconnection on WebSocket connection loss
|
|
17
|
+
*
|
|
18
|
+
* See the full documentation at https://trinityriversystems.com/docs/ for detailed
|
|
19
|
+
* protocol specifications and usage instructions.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// WebSocket environment shim:
|
|
23
|
+
// - In browsers, uses window.WebSocket
|
|
24
|
+
// - In Node.js, dynamically imports 'ws' and assigns globalThis.WebSocket
|
|
25
|
+
// All client code references the constructor via helper methods, not a top-level binding.
|
|
26
|
+
|
|
27
|
+
class GARClient {
|
|
28
|
+
/**
|
|
29
|
+
* Initialize the GAR client.
|
|
30
|
+
* @param {string} wsEndpoint - WebSocket endpoint (e.g., "ws://localhost:8765")
|
|
31
|
+
* @param {string} user - Client username for identification
|
|
32
|
+
* @param {number} [heartbeatTimeoutInterval=4000] - Heartbeat interval in milliseconds
|
|
33
|
+
* @param {boolean} [allowSelfSignedCertificate=false] - Allow self-signed certificates
|
|
34
|
+
* @param {string} [logLevel='INFO'] - Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
35
|
+
* @param {boolean} [uniqueKeyAndRecordUpdates=false] - Only one update per key/record
|
|
36
|
+
*/
|
|
37
|
+
constructor(wsEndpoint, user, working_namespace = null, heartbeatTimeoutInterval = 4000, allowSelfSignedCertificate = false, logLevel = 'INFO', uniqueKeyAndRecordUpdates = false) {
|
|
38
|
+
this.wsEndpoint = wsEndpoint;
|
|
39
|
+
this.websocket = null;
|
|
40
|
+
this.messageQueue = [];
|
|
41
|
+
this.connected = false;
|
|
42
|
+
this.reconnectDelay = 5000; // Milliseconds
|
|
43
|
+
this.user = user;
|
|
44
|
+
this.working_namespace = working_namespace;
|
|
45
|
+
this.timeoutScale = (typeof process !== 'undefined' && process.env && process.env.TRS_TIMEOUT_SCALE) ? parseFloat(process.env.TRS_TIMEOUT_SCALE) : 1.0;
|
|
46
|
+
this.scaledHeartbeatTimeoutInterval = heartbeatTimeoutInterval;
|
|
47
|
+
if (this.timeoutScale !== 1.0) {
|
|
48
|
+
this.scaledHeartbeatTimeoutInterval *= this.timeoutScale;
|
|
49
|
+
}
|
|
50
|
+
this.unique_key_and_record_updates = uniqueKeyAndRecordUpdates;
|
|
51
|
+
this.version = 650708;
|
|
52
|
+
this.uuid = GARClient._generateUUID();
|
|
53
|
+
|
|
54
|
+
if (typeof window !== 'undefined' && window.location) {
|
|
55
|
+
this.application = window.location.href;
|
|
56
|
+
} else if (typeof require !== 'undefined' && require.main) {
|
|
57
|
+
this.application = require.main.filename;
|
|
58
|
+
} else {
|
|
59
|
+
this.application = 'unknown-js';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.serverTopicIdToName = new Map();
|
|
63
|
+
this.serverTopicNameToId = new Map();
|
|
64
|
+
this.serverKeyIdToName = new Map();
|
|
65
|
+
this.serverKeyNameToId = new Map();
|
|
66
|
+
this.localTopicMap = new Map();
|
|
67
|
+
this.localKeyMap = new Map();
|
|
68
|
+
this.recordMap = new Map();
|
|
69
|
+
|
|
70
|
+
this.running = false;
|
|
71
|
+
this.heartbeatIntervalId = null;
|
|
72
|
+
this.messageHandlers = new Map();
|
|
73
|
+
this.lastHeartbeatTime = Date.now() / 1000;
|
|
74
|
+
|
|
75
|
+
this.heartbeatTimeoutCallback = null;
|
|
76
|
+
this.stoppedCallback = null;
|
|
77
|
+
this.allowSelfSignedCertificate = allowSelfSignedCertificate;
|
|
78
|
+
this.exitCode = 0;
|
|
79
|
+
|
|
80
|
+
this.clearConnectionState()
|
|
81
|
+
|
|
82
|
+
// Initialize log levels and set logLevel
|
|
83
|
+
this.logLevels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
|
84
|
+
this.logLevel = logLevel.toUpperCase();
|
|
85
|
+
if (!this.logLevels.includes(this.logLevel)) {
|
|
86
|
+
console.warn(`Invalid logLevel "${logLevel}" provided; defaulting to INFO`);
|
|
87
|
+
this.logLevel = 'INFO';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.registerDefaultHandlers();
|
|
91
|
+
|
|
92
|
+
// First-heartbeat latch: resolved on the first Heartbeat after an Introduction
|
|
93
|
+
this._firstHeartbeatResolve = null;
|
|
94
|
+
this._firstHeartbeatPromise = null;
|
|
95
|
+
this._resetFirstHeartbeatLatch();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Log messages with a specific level, only if the level is at or above the configured logLevel.
|
|
100
|
+
* @param {string} level - Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
101
|
+
* @param {string} message - Message to log
|
|
102
|
+
* @param {...any} args - Additional arguments
|
|
103
|
+
*/
|
|
104
|
+
log(level, message, ...args) {
|
|
105
|
+
const levelUpper = level.toUpperCase();
|
|
106
|
+
if (!this.logLevels.includes(levelUpper)) {
|
|
107
|
+
console.warn(`Invalid log level "${level}" in log call`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (this.logLevels.indexOf(levelUpper) >= this.logLevels.indexOf(this.logLevel)) {
|
|
111
|
+
const timestamp = new Date().toISOString();
|
|
112
|
+
console.log(`${timestamp} ${levelUpper} gar.js - ${message}`, ...args);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Reset all connection-related state:
|
|
118
|
+
* server/client topic/key mappings, counters, grace flags, and records.
|
|
119
|
+
*/
|
|
120
|
+
clearConnectionState() {
|
|
121
|
+
// Server assigned topic/key <-> name mappings
|
|
122
|
+
this.serverTopicIdToName.clear();
|
|
123
|
+
this.serverTopicNameToId.clear();
|
|
124
|
+
this.serverKeyIdToName.clear();
|
|
125
|
+
this.serverKeyNameToId.clear();
|
|
126
|
+
|
|
127
|
+
// Client assigned topic/key counters and name <-> ID maps
|
|
128
|
+
this.localTopicCounter = 1;
|
|
129
|
+
this.localKeyCounter = 1;
|
|
130
|
+
this.localTopicMap.clear();
|
|
131
|
+
this.localKeyMap.clear();
|
|
132
|
+
|
|
133
|
+
// Heartbeat grace period flags
|
|
134
|
+
this._initialGracePeriod = false;
|
|
135
|
+
this._initialGraceDeadline = 0;
|
|
136
|
+
|
|
137
|
+
// Cached records
|
|
138
|
+
this.recordMap.clear();
|
|
139
|
+
|
|
140
|
+
this.activeSubscriptionGroup = 0;
|
|
141
|
+
|
|
142
|
+
// Do NOT recreate the first-heartbeat promise here; callers might already be awaiting it.
|
|
143
|
+
// We only flip grace flags and let the existing promise resolve on the first Heartbeat.
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Establish WebSocket connection with reconnection logic.
|
|
148
|
+
* @param {number} [openTimeout=20] - Connection timeout in seconds
|
|
149
|
+
*/
|
|
150
|
+
async connect(openTimeout = 20) {
|
|
151
|
+
// Before attempting a new connection, clear any previous state
|
|
152
|
+
this.clearConnectionState();
|
|
153
|
+
|
|
154
|
+
let scaledOpenTimeout = openTimeout * this.timeoutScale;
|
|
155
|
+
|
|
156
|
+
while (this.running && !this.connected) {
|
|
157
|
+
try {
|
|
158
|
+
const WS = await this._ensureWebSocketCtor();
|
|
159
|
+
const isNode = typeof process !== 'undefined' && process.versions && process.versions.node;
|
|
160
|
+
|
|
161
|
+
this.log('INFO', `Connecting to WebSocket server at ${this.wsEndpoint}`);
|
|
162
|
+
|
|
163
|
+
const connectionPromise = new Promise((resolve, reject) => {
|
|
164
|
+
let websocket;
|
|
165
|
+
if (this.allowSelfSignedCertificate && isNode) {
|
|
166
|
+
// Node.js 'ws' supports options for TLS; browsers do not.
|
|
167
|
+
websocket = new WS(this.wsEndpoint, ['gar-protocol'], { rejectUnauthorized: false });
|
|
168
|
+
} else {
|
|
169
|
+
websocket = new WS(this.wsEndpoint, ['gar-protocol']);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const timeoutId = setTimeout(() => {
|
|
173
|
+
websocket.onopen = null;
|
|
174
|
+
websocket.onclose = null;
|
|
175
|
+
websocket.onerror = null;
|
|
176
|
+
if (websocket.terminate) websocket.terminate();
|
|
177
|
+
else if (websocket.close) websocket.close();
|
|
178
|
+
reject(new Error(`Connection timed out after ${scaledOpenTimeout}s`));
|
|
179
|
+
}, scaledOpenTimeout * 1000);
|
|
180
|
+
|
|
181
|
+
websocket.onopen = () => {
|
|
182
|
+
clearTimeout(timeoutId);
|
|
183
|
+
resolve(websocket);
|
|
184
|
+
};
|
|
185
|
+
websocket.onerror = (error) => {
|
|
186
|
+
clearTimeout(timeoutId);
|
|
187
|
+
reject(new Error(error.message || 'Unknown WebSocket error'));
|
|
188
|
+
};
|
|
189
|
+
websocket.onclose = () => {
|
|
190
|
+
clearTimeout(timeoutId);
|
|
191
|
+
reject(new Error('WebSocket closed during connection attempt'));
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
this.websocket = await connectionPromise;
|
|
196
|
+
this.connected = true;
|
|
197
|
+
this.log('INFO', `Connected to WebSocket server at ${this.wsEndpoint} using gar-protocol`);
|
|
198
|
+
|
|
199
|
+
this.websocket.onclose = () => {
|
|
200
|
+
this.log('WARNING', 'WebSocket connection closed.');
|
|
201
|
+
this.connected = false;
|
|
202
|
+
this.websocket = null;
|
|
203
|
+
this._reconnect();
|
|
204
|
+
};
|
|
205
|
+
this.websocket.onerror = (error) => {
|
|
206
|
+
this.log('ERROR', `WebSocket error: ${error.message || 'Unknown error'}`);
|
|
207
|
+
this.connected = false;
|
|
208
|
+
this.websocket = null;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
this._sendMessages();
|
|
212
|
+
this._receiveMessages();
|
|
213
|
+
|
|
214
|
+
} catch (e) {
|
|
215
|
+
this.log('ERROR', `WebSocket connection failed: ${e.message}. Reconnecting in ${this.reconnectDelay / 1000} seconds...`);
|
|
216
|
+
this.connected = false;
|
|
217
|
+
this.websocket = null;
|
|
218
|
+
await new Promise(resolve => setTimeout(resolve, this.reconnectDelay));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Attempt to reconnect after a delay.
|
|
225
|
+
*/
|
|
226
|
+
_reconnect() {
|
|
227
|
+
if (this.running && !this.connected) {
|
|
228
|
+
setTimeout(() => this.connect(), this.reconnectDelay);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Send messages from the queue to the WebSocket server.
|
|
234
|
+
*/
|
|
235
|
+
async _sendMessages() {
|
|
236
|
+
while (this.connected && (this.running || this.messageQueue.length > 0)) {
|
|
237
|
+
if (this.messageQueue.length === 0) {
|
|
238
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const message = this.messageQueue.shift();
|
|
243
|
+
if (message === null) break;
|
|
244
|
+
if (this._isSocketOpen()) {
|
|
245
|
+
this.websocket.send(JSON.stringify(message));
|
|
246
|
+
this.log('DEBUG', `Sent: ${JSON.stringify(message)}`);
|
|
247
|
+
}
|
|
248
|
+
} catch (e) {
|
|
249
|
+
this.log('WARNING', `Error sending message: ${e.message}`);
|
|
250
|
+
this.halt();
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
this.log('INFO', 'Done sending messages.');
|
|
255
|
+
this.stop()
|
|
256
|
+
this.connected = false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Receive and process messages from the WebSocket server.
|
|
261
|
+
*/
|
|
262
|
+
_receiveMessages() {
|
|
263
|
+
this.websocket.onmessage = (event) => {
|
|
264
|
+
const message = JSON.parse(event.data);
|
|
265
|
+
this._processMessage(message);
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Register a callback handler for a specific message type.
|
|
271
|
+
* @param {string} messageType - Type of message
|
|
272
|
+
* @param {Function} handler - Callback function
|
|
273
|
+
* @param subscriptionGroup - Subscription group for callback (default 0)
|
|
274
|
+
*/
|
|
275
|
+
registerHandler(messageType, handler, subscriptionGroup = 0) {
|
|
276
|
+
this.messageHandlers.set(subscriptionGroup ? `${messageType} ${subscriptionGroup}` : messageType, handler);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Register handler for Introduction message.
|
|
281
|
+
* @param {Function} handler - Callback with (version, heartbeatTimeoutInterval, user, _schema)
|
|
282
|
+
*/
|
|
283
|
+
registerIntroductionHandler(handler) {
|
|
284
|
+
this.registerHandler('Introduction', (msg) => {
|
|
285
|
+
const value = msg.value;
|
|
286
|
+
handler(value.version, value.heartbeat_timeout_interval, value.user, value.schema || null);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
clearIntroductionHandler() {
|
|
291
|
+
this.messageHandlers.delete('Introduction');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Register handler for Heartbeat message.
|
|
296
|
+
* @param {Function} handler - Callback with no arguments
|
|
297
|
+
*/
|
|
298
|
+
registerHeartbeatHandler(handler) {
|
|
299
|
+
this.registerHandler('Heartbeat', (msg) => {
|
|
300
|
+
const value = msg.value;
|
|
301
|
+
handler(value.u_milliseconds);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
clearHeartbeatHandler() {
|
|
306
|
+
this.messageHandlers.delete('Heartbeat');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Register handler for Logoff message.
|
|
311
|
+
* @param {Function} handler - Callback with no arguments
|
|
312
|
+
*/
|
|
313
|
+
registerLogoffHandler(handler) {
|
|
314
|
+
this.registerHandler('Logoff', () => handler());
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
clearLogoffHandler() {
|
|
318
|
+
this.messageHandlers.delete('Logoff');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Register handler for Error message.
|
|
323
|
+
* @param {Function} handler - Callback with (message)
|
|
324
|
+
*/
|
|
325
|
+
registerErrorHandler(handler) {
|
|
326
|
+
this.registerHandler('Error', (msg) => handler(msg.value.message));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
clearErrorHandler() {
|
|
330
|
+
this.messageHandlers.delete('Error');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Register handler for TopicIntroduction message.
|
|
335
|
+
* @param {Function} handler - Callback with (topicId, name)
|
|
336
|
+
*/
|
|
337
|
+
registerTopicIntroductionHandler(handler) {
|
|
338
|
+
this.registerHandler('TopicIntroduction', (msg) => {
|
|
339
|
+
const value = msg.value;
|
|
340
|
+
handler(value.topic_id, value.name);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
clearTopicIntroductionHandler() {
|
|
345
|
+
this.messageHandlers.delete('TopicIntroduction');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Register handler for KeyIntroduction message.
|
|
350
|
+
* @param {Function} handler - Callback with (keyId, name, class_list, deleted_class)
|
|
351
|
+
* @param subscriptionGroup - The subscription group for callback (default 0)
|
|
352
|
+
*/
|
|
353
|
+
registerKeyIntroductionHandler(handler, subscriptionGroup = 0) {
|
|
354
|
+
this.registerHandler('KeyIntroduction', (msg) => {
|
|
355
|
+
const value = msg.value;
|
|
356
|
+
handler(value.key_id, value.name, value.class_list || null, value.deleted_class || null);
|
|
357
|
+
}, subscriptionGroup);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Register handler for DeleteKey message.
|
|
362
|
+
* @param {Function} handler - Callback with (keyId, keyName)
|
|
363
|
+
* @param subscriptionGroup - The subscription group for callback (default 0)
|
|
364
|
+
*/
|
|
365
|
+
registerDeleteKeyHandler(handler, subscriptionGroup = 0) {
|
|
366
|
+
this.registerHandler('DeleteKey', (msg) => handler(msg.value.key_id, (msg.value && ('name' in msg.value)) ? msg.value.name : null), subscriptionGroup);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Register handler for SubscriptionStatus message.
|
|
371
|
+
* @param {Function} handler - Callback with (name, status)
|
|
372
|
+
* @param {number} [subscriptionGroup=0] - The subscription group for callback
|
|
373
|
+
*/
|
|
374
|
+
registerSubscriptionStatusHandler(handler, subscriptionGroup = 0) {
|
|
375
|
+
this.registerHandler('SubscriptionStatus', (msg) => handler(msg.value.name, msg.value.status), subscriptionGroup);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Clear handler for SubscriptionStatus message.
|
|
380
|
+
* @param {number} [subscriptionGroup=0] - The subscription group to clear
|
|
381
|
+
*/
|
|
382
|
+
clearSubscriptionStatusHandler(subscriptionGroup = 0) {
|
|
383
|
+
const key = subscriptionGroup ? `SubscriptionStatus ${subscriptionGroup}` : 'SubscriptionStatus';
|
|
384
|
+
this.messageHandlers.delete(key);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Register handler for DeleteRecord message.
|
|
389
|
+
* @param {Function} handler - Callback with (keyId, topicId)
|
|
390
|
+
* @param subscriptionGroup - The subscription group for callback (default 0)
|
|
391
|
+
*/
|
|
392
|
+
registerDeleteRecordHandler(handler, subscriptionGroup = 0) {
|
|
393
|
+
this.registerHandler('DeleteRecord', (msg) => {
|
|
394
|
+
handler(msg.value.key_id, msg.value.topic_id);
|
|
395
|
+
}, subscriptionGroup);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Register handler for JSONRecordUpdate message.
|
|
400
|
+
* @param {Function} handler - Callback with (keyId, topicId, value)
|
|
401
|
+
* @param subscriptionGroup - The subscription group for callback (default 0)
|
|
402
|
+
*/
|
|
403
|
+
registerRecordUpdateHandler(handler, subscriptionGroup = 0) {
|
|
404
|
+
this.registerHandler('JSONRecordUpdate', (msg) => {
|
|
405
|
+
const recordId = msg.value.record_id;
|
|
406
|
+
handler(recordId.key_id, recordId.topic_id, msg.value.value);
|
|
407
|
+
}, subscriptionGroup);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Register handler for JSONCompareExchangeResult message.
|
|
412
|
+
* @param {Function} handler - Callback with (message)
|
|
413
|
+
* @param {number} [subscriptionGroup=0] - The subscription group for callback
|
|
414
|
+
*/
|
|
415
|
+
registerCompareExchangeResultHandler(handler, subscriptionGroup = 0) {
|
|
416
|
+
this.registerHandler('JSONCompareExchangeResult', handler, subscriptionGroup);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Register handler for BatchUpdate message.
|
|
421
|
+
* If a batch handler is registered it is expected to process all the updates in the batch.
|
|
422
|
+
* If no batch handler is registered, individual key introductions and record updates will be fanned out to their respective handlers.
|
|
423
|
+
* @param {Function} handler - Callback with (batchData, subscriptionGroup)
|
|
424
|
+
* @param {number} [subscriptionGroup=0] - The subscription group for callback
|
|
425
|
+
*/
|
|
426
|
+
registerBatchUpdateHandler(handler, subscriptionGroup = 0) {
|
|
427
|
+
this.registerHandler('BatchUpdate', (msg) => {
|
|
428
|
+
handler(msg.value, subscriptionGroup);
|
|
429
|
+
}, subscriptionGroup);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Register handler for EchoResponse message.
|
|
434
|
+
* @param {Function} handler - Callback with (msgRef)
|
|
435
|
+
*/
|
|
436
|
+
registerEchoResponseHandler(handler) {
|
|
437
|
+
this.registerHandler('EchoResponse', (msg) => {
|
|
438
|
+
handler(msg.value.msg_ref);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
clearEchoResponseHandler() {
|
|
443
|
+
this.messageHandlers.delete('EchoResponse');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Register a callback to handle heartbeat timeout events.
|
|
448
|
+
* @param {Function} handler - Callback with no arguments
|
|
449
|
+
*/
|
|
450
|
+
registerHeartbeatTimeoutHandler(handler) {
|
|
451
|
+
this.heartbeatTimeoutCallback = handler;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
clearHeartbeatTimeoutHandler() {
|
|
455
|
+
this.heartbeatTimeoutCallback = null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Register a callback to handle client stopped events.
|
|
460
|
+
* @param {Function} handler - Callback with no arguments
|
|
461
|
+
*/
|
|
462
|
+
registerStoppedHandler(handler) {
|
|
463
|
+
this.stoppedCallback = handler;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
clearStoppedHandler() {
|
|
467
|
+
this.stoppedCallback = null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Register default logging handlers for all message types.
|
|
472
|
+
*/
|
|
473
|
+
registerDefaultHandlers() {
|
|
474
|
+
this.registerIntroductionHandler((version, interval, user, _schema) =>
|
|
475
|
+
this.log('INFO', `Connected to server: ${user}`));
|
|
476
|
+
this.registerHeartbeatHandler((u_milliseconds) => this.log('INFO', `Heartbeat received ${u_milliseconds}ms`));
|
|
477
|
+
this.registerLogoffHandler(() => this.log('INFO', 'Logoff received'));
|
|
478
|
+
this.registerTopicIntroductionHandler((topicId, name) =>
|
|
479
|
+
this.log('DEBUG', `New server topic: ${name} (Server ID: ${topicId})`));
|
|
480
|
+
this.registerKeyIntroductionHandler((keyId, name, classList, deletedClass) =>
|
|
481
|
+
this.log('DEBUG', `Key: ${name} : ${keyId} (Classes: ${JSON.stringify(classList)}/-${deletedClass})`));
|
|
482
|
+
this.registerDeleteKeyHandler((keyId, keyName) =>
|
|
483
|
+
this.log('DEBUG', `Delete key: ${keyName || 'unknown'} (Server ID: ${keyId})`));
|
|
484
|
+
this.registerSubscriptionStatusHandler(this._defaultSubscriptionStatusHandler.bind(this));
|
|
485
|
+
this.registerDeleteRecordHandler((keyId, topicId) =>
|
|
486
|
+
this.log('DEBUG', `Delete record: ${this.serverKeyIdToName.get(keyId) || 'unknown'} - ${this.serverTopicIdToName.get(topicId) || 'unknown'}`));
|
|
487
|
+
this.registerRecordUpdateHandler((keyId, topicId, value) =>
|
|
488
|
+
this.log('DEBUG', `Record update: ${this.serverKeyIdToName.get(keyId) || 'unknown'} - ${this.serverTopicIdToName.get(topicId) || 'unknown'} = ${JSON.stringify(value)}`));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Default handler for subscription status messages.
|
|
493
|
+
* @param {string} name - Subscription name
|
|
494
|
+
* @param {string} status - Subscription status
|
|
495
|
+
*/
|
|
496
|
+
_defaultSubscriptionStatusHandler(name, status) {
|
|
497
|
+
this.log('INFO', `Subscription ${name} status: ${status}`);
|
|
498
|
+
if (status === 'NeedsContinue') {
|
|
499
|
+
this.log('INFO', `Snapshot size limit reached, sending SubscribeContinue for ${name}`);
|
|
500
|
+
this.sendSubscribeContinue(name);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Start the client and send introduction message.
|
|
506
|
+
*/
|
|
507
|
+
start() {
|
|
508
|
+
this.running = true;
|
|
509
|
+
const introMsg = {
|
|
510
|
+
message_type: 'Introduction',
|
|
511
|
+
value: {
|
|
512
|
+
version: this.version,
|
|
513
|
+
heartbeat_timeout_interval: Math.floor(this.scaledHeartbeatTimeoutInterval),
|
|
514
|
+
unique_key_and_record_updates: this.unique_key_and_record_updates,
|
|
515
|
+
uuid: this.uuid,
|
|
516
|
+
user: this.user,
|
|
517
|
+
working_namespace: this.working_namespace
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
this.sendMessage(introMsg);
|
|
521
|
+
this.heartbeatIntervalId = setInterval(() => this._heartbeatLoop(), this.scaledHeartbeatTimeoutInterval / 2);
|
|
522
|
+
this.connect();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Wait until in-memory queue is empty and WebSocket bufferedAmount is zero.
|
|
527
|
+
*/
|
|
528
|
+
async _waitForDrain(pollMs = 50) {
|
|
529
|
+
while (
|
|
530
|
+
this.messageQueue.length > 0 ||
|
|
531
|
+
(this.websocket && this.websocket.bufferedAmount > 0)
|
|
532
|
+
) {
|
|
533
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Stop the client and terminate all operations.
|
|
539
|
+
*/
|
|
540
|
+
async stop() {
|
|
541
|
+
if (!this.running)
|
|
542
|
+
return;
|
|
543
|
+
|
|
544
|
+
this.running = false;
|
|
545
|
+
if (this.heartbeatIntervalId) {
|
|
546
|
+
clearInterval(this.heartbeatIntervalId);
|
|
547
|
+
this.heartbeatIntervalId = null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// if it's a regular stop, wait for send-queue + socket to drain
|
|
551
|
+
if (this.connected && this.websocket) {
|
|
552
|
+
await this._waitForDrain();
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
this.messageQueue = [];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (this.websocket && this.websocket.readyState === 1) {
|
|
559
|
+
this.websocket.close();
|
|
560
|
+
}
|
|
561
|
+
this.messageQueue = [];
|
|
562
|
+
if (this.stoppedCallback) {
|
|
563
|
+
this.stoppedCallback();
|
|
564
|
+
}
|
|
565
|
+
this.log('INFO', 'GAR client stopped');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
_isSocketOpen() {
|
|
569
|
+
// Both browser and 'ws' use readyState 1 for OPEN
|
|
570
|
+
return !!(this.websocket && this.websocket.readyState === 1);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async _ensureWebSocketCtor() {
|
|
574
|
+
// Return a WebSocket constructor for current environment.
|
|
575
|
+
// Prefer any existing globalThis.WebSocket (browser or pre-set in Node).
|
|
576
|
+
if (typeof globalThis !== 'undefined' && globalThis.WebSocket) {
|
|
577
|
+
return globalThis.WebSocket;
|
|
578
|
+
}
|
|
579
|
+
// Node.js: resolve 'ws' at runtime without using dynamic import (avoids rollup code-splitting)
|
|
580
|
+
const isNode = typeof process !== 'undefined' && process.versions && process.versions.node;
|
|
581
|
+
if (isNode) {
|
|
582
|
+
try {
|
|
583
|
+
const req = (typeof module !== 'undefined' && module.require)
|
|
584
|
+
? module.require.bind(module)
|
|
585
|
+
: (typeof require !== 'undefined' ? require : Function('return require')());
|
|
586
|
+
const mod = req('ws');
|
|
587
|
+
const WS = mod.default || mod.WebSocket || mod;
|
|
588
|
+
globalThis.WebSocket = WS;
|
|
589
|
+
return WS;
|
|
590
|
+
} catch {
|
|
591
|
+
throw new Error("'ws' module is required in Node.js environment. Please install it: npm install ws");
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
throw new Error('WebSocket constructor not found in this environment');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Stop the client without sending pending messages.
|
|
599
|
+
*/
|
|
600
|
+
halt() {
|
|
601
|
+
this.connected = false;
|
|
602
|
+
this.stop();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Send a logoff message to the server and stop the client.
|
|
607
|
+
*/
|
|
608
|
+
logoff() {
|
|
609
|
+
this.sendMessage({ message_type: 'Logoff' });
|
|
610
|
+
this.stop();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Send a JSON message through the WebSocket.
|
|
615
|
+
* @param {Object} message - Message to send
|
|
616
|
+
*/
|
|
617
|
+
sendMessage(message) {
|
|
618
|
+
this.messageQueue.push(message);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Send a SubscribeContinue message for a subscription name.
|
|
623
|
+
* @param {string} name - Subscription name
|
|
624
|
+
*/
|
|
625
|
+
sendSubscribeContinue(name) {
|
|
626
|
+
const msg = {
|
|
627
|
+
message_type: 'SubscribeContinue',
|
|
628
|
+
value: { name }
|
|
629
|
+
};
|
|
630
|
+
this.sendMessage(msg);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Send an EchoRequest message.
|
|
635
|
+
* @param {number} msgRef - Message reference ID
|
|
636
|
+
*/
|
|
637
|
+
sendEchoRequest(msgRef) {
|
|
638
|
+
this.sendMessage({
|
|
639
|
+
message_type: 'EchoRequest',
|
|
640
|
+
value: { msg_ref: msgRef }
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Send periodic heartbeat messages.
|
|
646
|
+
*/
|
|
647
|
+
_heartbeatLoop() {
|
|
648
|
+
if (this.running) {
|
|
649
|
+
this.sendMessage({ message_type: 'Heartbeat', value: { u_milliseconds: Date.now() } });
|
|
650
|
+
this.checkHeartbeat();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Check if the heartbeat has timed out.
|
|
656
|
+
*/
|
|
657
|
+
checkHeartbeat() {
|
|
658
|
+
const now = Date.now() / 1000;
|
|
659
|
+
const cutoff = this._initialGracePeriod ? this._initialGraceDeadline : this.lastHeartbeatTime + (this.scaledHeartbeatTimeoutInterval / 1000.0);
|
|
660
|
+
if (now > cutoff) {
|
|
661
|
+
this.log('WARNING', `Heartbeat failed; previous heartbeat: ${this.lastHeartbeatTime.toFixed(3)}s`);
|
|
662
|
+
this.exitCode = 1;
|
|
663
|
+
this.halt();
|
|
664
|
+
if (this.heartbeatTimeoutCallback) {
|
|
665
|
+
this.heartbeatTimeoutCallback();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Reset the internal first-heartbeat latch and create a fresh Promise.
|
|
672
|
+
* Called on construction, clearConnectionState, and Introduction.
|
|
673
|
+
* @private
|
|
674
|
+
*/
|
|
675
|
+
_resetFirstHeartbeatLatch() {
|
|
676
|
+
let resolved = false;
|
|
677
|
+
this._firstHeartbeatPromise = new Promise((resolve) => {
|
|
678
|
+
this._firstHeartbeatResolve = (value) => {
|
|
679
|
+
if (!resolved) {
|
|
680
|
+
resolved = true;
|
|
681
|
+
resolve(value);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Wait for the first heartbeat after the most recent Introduction.
|
|
689
|
+
* @param {number} [timeoutMs=10000] - Timeout in milliseconds
|
|
690
|
+
* @returns {Promise<void>} Resolves on first heartbeat; rejects on timeout
|
|
691
|
+
*/
|
|
692
|
+
awaitFirstHeartbeat(timeoutMs = 10000) {
|
|
693
|
+
const to = Math.max(0, Math.floor(timeoutMs));
|
|
694
|
+
return new Promise((resolve, reject) => {
|
|
695
|
+
let timer = null;
|
|
696
|
+
if (to > 0) {
|
|
697
|
+
timer = setTimeout(() => {
|
|
698
|
+
reject(new Error('Timed out waiting for first heartbeat'));
|
|
699
|
+
}, to);
|
|
700
|
+
}
|
|
701
|
+
this._firstHeartbeatPromise
|
|
702
|
+
.then(() => {
|
|
703
|
+
if (timer) clearTimeout(timer);
|
|
704
|
+
resolve();
|
|
705
|
+
})
|
|
706
|
+
.catch((e) => {
|
|
707
|
+
if (timer) clearTimeout(timer);
|
|
708
|
+
reject(e);
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Process incoming messages by calling registered handlers.
|
|
715
|
+
* @param {Object} message - Incoming message
|
|
716
|
+
*/
|
|
717
|
+
_processMessage(message) {
|
|
718
|
+
const msgType = message.message_type;
|
|
719
|
+
let subscriptionGroup = 0;
|
|
720
|
+
if (msgType === 'TopicIntroduction') {
|
|
721
|
+
this.serverTopicIdToName.set(message.value.topic_id, message.value.name);
|
|
722
|
+
this.serverTopicNameToId.set(message.value.name, message.value.topic_id);
|
|
723
|
+
} else if (msgType === 'KeyIntroduction') {
|
|
724
|
+
subscriptionGroup = this.activeSubscriptionGroup;
|
|
725
|
+
this.serverKeyIdToName.set(message.value.key_id, message.value.name);
|
|
726
|
+
this.serverKeyNameToId.set(message.value.name, message.value.key_id);
|
|
727
|
+
} else if (msgType === 'DeleteKey') {
|
|
728
|
+
subscriptionGroup = this.activeSubscriptionGroup;
|
|
729
|
+
const keyId = message.value.key_id;
|
|
730
|
+
const keyName = this.serverKeyIdToName.get(keyId) || null;
|
|
731
|
+
message.value.name = keyName;
|
|
732
|
+
if (keyName) {
|
|
733
|
+
this.serverKeyNameToId.delete(keyName);
|
|
734
|
+
} else {
|
|
735
|
+
// Ensure 'name' field exists even if unknown
|
|
736
|
+
if (!('name' in message.value)) message.value.name = null;
|
|
737
|
+
}
|
|
738
|
+
this.serverKeyIdToName.delete(keyId);
|
|
739
|
+
} else if (msgType === 'Heartbeat') {
|
|
740
|
+
this.lastHeartbeatTime = Date.now() / 1000;
|
|
741
|
+
if (this._initialGracePeriod) {
|
|
742
|
+
// Resolve first-heartbeat waiters and end the initial grace window
|
|
743
|
+
if (this._firstHeartbeatResolve) {
|
|
744
|
+
try { this._firstHeartbeatResolve(); } catch { /* noop */ }
|
|
745
|
+
}
|
|
746
|
+
this._initialGracePeriod = false;
|
|
747
|
+
}
|
|
748
|
+
} else if (msgType === 'Introduction') {
|
|
749
|
+
const value = message.value;
|
|
750
|
+
this.serverTopicIdToName.clear();
|
|
751
|
+
this.serverTopicNameToId.clear();
|
|
752
|
+
this.serverKeyIdToName.clear();
|
|
753
|
+
this.serverKeyNameToId.clear();
|
|
754
|
+
this.recordMap.clear();
|
|
755
|
+
let newInterval = value.heartbeat_timeout_interval;
|
|
756
|
+
if (this.timeoutScale !== 1.0) {
|
|
757
|
+
newInterval *= this.timeoutScale;
|
|
758
|
+
}
|
|
759
|
+
this.scaledHeartbeatTimeoutInterval = Math.max(this.scaledHeartbeatTimeoutInterval, newInterval);
|
|
760
|
+
this.lastHeartbeatTime = Date.now() / 1000;
|
|
761
|
+
this._initialGracePeriod = true;
|
|
762
|
+
this._initialGraceDeadline = this.lastHeartbeatTime + (this.scaledHeartbeatTimeoutInterval / 1000.0) * 10;
|
|
763
|
+
// New Introduction: do not reset the first-heartbeat promise to avoid racing
|
|
764
|
+
// with consumers already awaiting it. The existing promise will resolve on
|
|
765
|
+
// the first Heartbeat observed during the grace period above.
|
|
766
|
+
} else if (msgType === 'JSONCompareExchangeResult') {
|
|
767
|
+
subscriptionGroup = this.activeSubscriptionGroup;
|
|
768
|
+
} else if (msgType === 'JSONRecordUpdate') {
|
|
769
|
+
subscriptionGroup = this.activeSubscriptionGroup;
|
|
770
|
+
const recordId = message.value.record_id;
|
|
771
|
+
const keyId = recordId.key_id;
|
|
772
|
+
const topicId = recordId.topic_id;
|
|
773
|
+
this.recordMap.set(`${keyId}:${topicId}`, message.value.value);
|
|
774
|
+
} else if (msgType === 'SubscriptionStatus') {
|
|
775
|
+
// Route status notifications to the currently active subscription group
|
|
776
|
+
subscriptionGroup = this.activeSubscriptionGroup;
|
|
777
|
+
} else if (msgType === 'DeleteRecord') {
|
|
778
|
+
subscriptionGroup = this.activeSubscriptionGroup;
|
|
779
|
+
const {key_id: keyId, topic_id: topicId} = message.value;
|
|
780
|
+
this.recordMap.delete(`${keyId}:${topicId}`);
|
|
781
|
+
} else if (msgType === 'BatchUpdate') {
|
|
782
|
+
subscriptionGroup = this.activeSubscriptionGroup;
|
|
783
|
+
const value = message.value;
|
|
784
|
+
const defaultClass = value.default_class;
|
|
785
|
+
|
|
786
|
+
// Check if there's a specific batch update handler
|
|
787
|
+
const batchHandlerKey = subscriptionGroup ? `BatchUpdate ${subscriptionGroup}` : 'BatchUpdate';
|
|
788
|
+
const hasBatchHandler = this.messageHandlers.has(batchHandlerKey);
|
|
789
|
+
|
|
790
|
+
// Pre-check for individual handlers if no batch handler
|
|
791
|
+
let keyHandler = null;
|
|
792
|
+
let recordHandler = null;
|
|
793
|
+
if (!hasBatchHandler) {
|
|
794
|
+
const keyHandlerKey = subscriptionGroup ? `KeyIntroduction ${subscriptionGroup}` : 'KeyIntroduction';
|
|
795
|
+
keyHandler = this.messageHandlers.get(keyHandlerKey);
|
|
796
|
+
|
|
797
|
+
const recordHandlerKey = subscriptionGroup ? `JSONRecordUpdate ${subscriptionGroup}` : 'JSONRecordUpdate';
|
|
798
|
+
recordHandler = this.messageHandlers.get(recordHandlerKey);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
for (const keyUpdate of value.keys || []) {
|
|
802
|
+
const keyId = keyUpdate.key_id;
|
|
803
|
+
const keyName = keyUpdate.name;
|
|
804
|
+
|
|
805
|
+
// Handle key introduction if name is provided and key is new
|
|
806
|
+
if (keyName && !this.serverKeyIdToName.has(keyId)) {
|
|
807
|
+
this.serverKeyIdToName.set(keyId, keyName);
|
|
808
|
+
this.serverKeyNameToId.set(keyName, keyId);
|
|
809
|
+
|
|
810
|
+
// If no batch handler but key handler exists, call KeyIntroduction handler
|
|
811
|
+
if (!hasBatchHandler && keyHandler) {
|
|
812
|
+
// Determine class_list: use key's classes, or default_class, or null
|
|
813
|
+
let keyClasses = keyUpdate.classes;
|
|
814
|
+
if (!keyClasses && keyUpdate.class) {
|
|
815
|
+
keyClasses = [keyUpdate.class];
|
|
816
|
+
} else if (!keyClasses && defaultClass) {
|
|
817
|
+
keyClasses = [defaultClass];
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const keyIntroMsg = {
|
|
821
|
+
message_type: 'KeyIntroduction',
|
|
822
|
+
value: {
|
|
823
|
+
key_id: keyId,
|
|
824
|
+
name: keyName,
|
|
825
|
+
...(keyClasses ? { class_list: keyClasses } : {}),
|
|
826
|
+
deleted_class: null
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
keyHandler(keyIntroMsg);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Process topics for this key - topic IDs are object keys
|
|
834
|
+
const topicsDict = keyUpdate.topics || {};
|
|
835
|
+
for (const [topicIdStr, recordValue] of Object.entries(topicsDict)) {
|
|
836
|
+
const topicId = parseInt(topicIdStr, 10);
|
|
837
|
+
this.recordMap.set(`${keyId}:${topicId}`, recordValue);
|
|
838
|
+
|
|
839
|
+
// If no batch handler but record handler exists, call JSONRecordUpdate handler
|
|
840
|
+
if (!hasBatchHandler && recordHandler) {
|
|
841
|
+
const recordUpdateMsg = {
|
|
842
|
+
message_type: 'JSONRecordUpdate',
|
|
843
|
+
value: {
|
|
844
|
+
record_id: { key_id: keyId, topic_id: topicId },
|
|
845
|
+
value: recordValue
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
recordHandler(recordUpdateMsg);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// If there is a batch handler, call it
|
|
854
|
+
if (hasBatchHandler) {
|
|
855
|
+
const batchHandler = this.messageHandlers.get(batchHandlerKey);
|
|
856
|
+
if (batchHandler) {
|
|
857
|
+
batchHandler(message);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
} else if (msgType === "ActiveSubscription") {
|
|
861
|
+
this.activeSubscriptionGroup = message["value"]["subscription_group"]
|
|
862
|
+
} else if (msgType === 'Logoff') {
|
|
863
|
+
this.log('INFO', 'Received Logoff from server');
|
|
864
|
+
this.stop();
|
|
865
|
+
} else if (msgType === 'Error') {
|
|
866
|
+
this.log('ERROR', `GAR ${message.value.message}`);
|
|
867
|
+
this.exitCode = 1;
|
|
868
|
+
this.stop();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
this.checkHeartbeat();
|
|
872
|
+
|
|
873
|
+
const handler = this.messageHandlers.get(subscriptionGroup ? `${msgType} ${subscriptionGroup}` : msgType);
|
|
874
|
+
if (handler) {
|
|
875
|
+
handler(message);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Send an already-formatted subscription message
|
|
881
|
+
* @param {object} subscriptionMessageValue - json representation of the gar `subscribe` struct
|
|
882
|
+
*/
|
|
883
|
+
subscribeFormatted(subscriptionMessageValue) {
|
|
884
|
+
const subMsg = {
|
|
885
|
+
message_type: 'Subscribe',
|
|
886
|
+
value: subscriptionMessageValue,
|
|
887
|
+
};
|
|
888
|
+
this.sendMessage(subMsg);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Send a subscription request
|
|
893
|
+
* @param {string} name - Subscription name
|
|
894
|
+
* @param {string} [subscriptionMode='Streaming'] - Subscription mode
|
|
895
|
+
* @param {string|Array<string>|null} [keyName=null] - Key name(s)
|
|
896
|
+
* @param {string|Array<string>|null} [topicName=null] - Topic name(s)
|
|
897
|
+
* @param {string|Array<string>|null} [className=null] - Class name(s)
|
|
898
|
+
* @param {string|null} [keyFilter=null] - Key filter regex (cannot use with keyName)
|
|
899
|
+
* @param {string|null} [topicFilter=null] - Topic filter regex (cannot use with topicName)
|
|
900
|
+
* @param {string|null} [excludeKeyFilter=null] - Exclude key filter regex (cannot use with keyName)
|
|
901
|
+
* @param {string|null} [excludeTopicFilter=null] - Exclude topic filter regex (cannot use with topicName)
|
|
902
|
+
* @param {string|null} [maxHistory] - Maximum history to include
|
|
903
|
+
* @param {boolean} [includeDerived=false] - Include derived topics
|
|
904
|
+
* @param {boolean} [trimDefaultValues=false] - Trim records containing default values from the snapshot
|
|
905
|
+
* @param {string|null} [workingNamespace] - Namespace for matching relative paths
|
|
906
|
+
* @param {string|null} [restrictNamespace=false] - Restricts topics and keys to children of restrict_namespace. Defaults to the working namespace. Use "::" for root / no restriction.
|
|
907
|
+
* @param {string|null} [density] - For performance tuning
|
|
908
|
+
* @param {number} [subscriptionGroup=0] - Subscription group ID for isolating callbacks
|
|
909
|
+
* @param {string|null} [subscriptionSet] - Subscription set identifier
|
|
910
|
+
* @param {number} [snapshotSizeLimit=0] - Limit snapshot size
|
|
911
|
+
* @param {number} [nagleInterval=0] - Nagle interval in milliseconds
|
|
912
|
+
* @param {number} [limit=0] - Limits records in initial snapshot (0 = all)
|
|
913
|
+
* @param {string|Array<string>|null} [referencingClassList=null] - Referencing class name(s); if provided, include referencing keys and records per proto.gar subscribe.referencing_class_list
|
|
914
|
+
*/
|
|
915
|
+
subscribe(
|
|
916
|
+
name,
|
|
917
|
+
subscriptionMode = 'Streaming',
|
|
918
|
+
keyName = null,
|
|
919
|
+
topicName = null,
|
|
920
|
+
className = null,
|
|
921
|
+
keyFilter = null,
|
|
922
|
+
topicFilter = null,
|
|
923
|
+
excludeKeyFilter = null,
|
|
924
|
+
excludeTopicFilter = null,
|
|
925
|
+
maxHistory = null,
|
|
926
|
+
includeDerived = false,
|
|
927
|
+
trimDefaultValues = false,
|
|
928
|
+
workingNamespace = null,
|
|
929
|
+
restrictNamespace = null,
|
|
930
|
+
density = null,
|
|
931
|
+
subscriptionGroup = 0,
|
|
932
|
+
subscriptionSet = null,
|
|
933
|
+
snapshotSizeLimit = 0,
|
|
934
|
+
nagleInterval = 0,
|
|
935
|
+
limit = 0,
|
|
936
|
+
referencingClassList = null
|
|
937
|
+
) {
|
|
938
|
+
// Validate mutually exclusive parameters
|
|
939
|
+
if (keyName && (keyFilter || excludeKeyFilter)) {
|
|
940
|
+
throw new Error('keyName cannot be used with keyFilter or excludeKeyFilter');
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (topicName && (topicFilter || excludeTopicFilter)) {
|
|
944
|
+
throw new Error('topicName cannot be used with topicFilter or excludeTopicFilter');
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Validate limit parameter usage
|
|
948
|
+
if (limit > 0 && subscriptionMode === 'Streaming') {
|
|
949
|
+
throw new Error('limit cannot be used with streaming subscriptions');
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Convert className to array
|
|
953
|
+
let classList = null;
|
|
954
|
+
if (typeof className === 'string') {
|
|
955
|
+
classList = className.split(/\s+/).filter(Boolean);
|
|
956
|
+
} else if (Array.isArray(className)) {
|
|
957
|
+
classList = className;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Normalize referencingClassList to an array
|
|
961
|
+
let referencingClasses = null;
|
|
962
|
+
if (typeof referencingClassList === 'string') {
|
|
963
|
+
referencingClasses = referencingClassList.split(/\s+/).filter(Boolean);
|
|
964
|
+
} else if (Array.isArray(referencingClassList)) {
|
|
965
|
+
referencingClasses = referencingClassList;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
let singleClass = Array.isArray(classList) && classList.length === 1 ? classList[0] : null;
|
|
969
|
+
|
|
970
|
+
// Convert keyName to array
|
|
971
|
+
let keyNames = [];
|
|
972
|
+
if (typeof keyName === 'string') {
|
|
973
|
+
keyNames = [keyName];
|
|
974
|
+
} else if (Array.isArray(keyName)) {
|
|
975
|
+
keyNames = keyName;
|
|
976
|
+
}
|
|
977
|
+
const keyIdList = keyNames.map(x => this.getAndPossiblyIntroduceKeyId(x, singleClass));
|
|
978
|
+
|
|
979
|
+
// Convert topicName to array
|
|
980
|
+
let topicNames = [];
|
|
981
|
+
if (typeof topicName === 'string') {
|
|
982
|
+
topicNames = topicName.split(/\s+/).filter(Boolean);
|
|
983
|
+
} else if (Array.isArray(topicName)) {
|
|
984
|
+
topicNames = topicName;
|
|
985
|
+
}
|
|
986
|
+
const topicIdList = topicNames.map(x => this.getAndPossiblyIntroduceTopicId(x));
|
|
987
|
+
|
|
988
|
+
// Build subscription message, filtering out null/undefined values
|
|
989
|
+
const valueDict = {
|
|
990
|
+
subscription_mode: subscriptionMode,
|
|
991
|
+
name,
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
// Add optional fields only if they have values
|
|
995
|
+
if (subscriptionSet) {
|
|
996
|
+
valueDict.subscription_set = subscriptionSet;
|
|
997
|
+
}
|
|
998
|
+
if (maxHistory) {
|
|
999
|
+
valueDict.max_history = maxHistory;
|
|
1000
|
+
}
|
|
1001
|
+
if (snapshotSizeLimit > 0) {
|
|
1002
|
+
valueDict.snapshot_size_limit = snapshotSizeLimit;
|
|
1003
|
+
}
|
|
1004
|
+
if (nagleInterval > 0) {
|
|
1005
|
+
valueDict.nagle_interval = nagleInterval;
|
|
1006
|
+
}
|
|
1007
|
+
if (subscriptionGroup > 0) {
|
|
1008
|
+
valueDict.subscription_group = subscriptionGroup;
|
|
1009
|
+
}
|
|
1010
|
+
if (density) {
|
|
1011
|
+
valueDict.density = density;
|
|
1012
|
+
}
|
|
1013
|
+
if (includeDerived) {
|
|
1014
|
+
valueDict.include_derived = includeDerived;
|
|
1015
|
+
}
|
|
1016
|
+
if (restrictNamespace) {
|
|
1017
|
+
valueDict.restrict_namespace = restrictNamespace;
|
|
1018
|
+
}
|
|
1019
|
+
if (trimDefaultValues) {
|
|
1020
|
+
valueDict.trim_default_values = trimDefaultValues;
|
|
1021
|
+
}
|
|
1022
|
+
if (limit > 0) {
|
|
1023
|
+
valueDict.limit = limit;
|
|
1024
|
+
}
|
|
1025
|
+
if (keyIdList.length > 0) {
|
|
1026
|
+
valueDict.key_id_list = keyIdList;
|
|
1027
|
+
}
|
|
1028
|
+
if (topicIdList.length > 0) {
|
|
1029
|
+
valueDict.topic_id_list = topicIdList;
|
|
1030
|
+
}
|
|
1031
|
+
if (classList) {
|
|
1032
|
+
valueDict.class_list = classList;
|
|
1033
|
+
}
|
|
1034
|
+
if (referencingClasses && referencingClasses.length > 0) {
|
|
1035
|
+
valueDict.referencing_class_list = referencingClasses;
|
|
1036
|
+
}
|
|
1037
|
+
if (keyFilter) {
|
|
1038
|
+
valueDict.key_filter = keyFilter;
|
|
1039
|
+
}
|
|
1040
|
+
if (excludeKeyFilter) {
|
|
1041
|
+
valueDict.exclude_key_filter = excludeKeyFilter;
|
|
1042
|
+
}
|
|
1043
|
+
if (topicFilter) {
|
|
1044
|
+
valueDict.topic_filter = topicFilter;
|
|
1045
|
+
}
|
|
1046
|
+
if (excludeTopicFilter) {
|
|
1047
|
+
valueDict.exclude_topic_filter = excludeTopicFilter;
|
|
1048
|
+
}
|
|
1049
|
+
if (workingNamespace) {
|
|
1050
|
+
valueDict.working_namespace = workingNamespace;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
this.subscribeFormatted(valueDict);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Wrapper to `subscribe` with a smaller set of arguments.
|
|
1058
|
+
* @param {string} name - Subscription name
|
|
1059
|
+
* @param {string} [subscriptionMode='Streaming'] - Subscription mode
|
|
1060
|
+
* @param {number} [subscriptionGroup=0] - Subscription group ID for isolating callbacks
|
|
1061
|
+
* @param {number} [snapshotSizeLimit=0] - Limit snapshot size
|
|
1062
|
+
* @param {string|Array<string>|null} [keyName=null] - Key name(s)
|
|
1063
|
+
* @param {string|Array<string>|null} [topicName=null] - Topic name(s)
|
|
1064
|
+
* @param {string|Array<string>|null} [className=null] - Class name(s)
|
|
1065
|
+
* @param {string|null} [keyFilter=null] - Key filter regex (cannot use with keyName)
|
|
1066
|
+
* @param {string|null} [topicFilter=null] - Topic filter regex (cannot use with topicName)
|
|
1067
|
+
* @param {string|null} [excludeKeyFilter=null] - Exclude key filter regex (cannot use with keyName)
|
|
1068
|
+
* @param {string|null} [excludeTopicFilter=null] - Exclude topic filter regex (cannot use with topicName)
|
|
1069
|
+
*/
|
|
1070
|
+
subscribeCommon(
|
|
1071
|
+
name,
|
|
1072
|
+
subscriptionMode = 'Streaming',
|
|
1073
|
+
subscriptionGroup = 0,
|
|
1074
|
+
snapshotSizeLimit = 0,
|
|
1075
|
+
keyName = null,
|
|
1076
|
+
topicName = null,
|
|
1077
|
+
className = null,
|
|
1078
|
+
keyFilter = null,
|
|
1079
|
+
topicFilter = null,
|
|
1080
|
+
excludeKeyFilter = null,
|
|
1081
|
+
excludeTopicFilter = null,
|
|
1082
|
+
) {
|
|
1083
|
+
this.subscribe(
|
|
1084
|
+
name,
|
|
1085
|
+
subscriptionMode,
|
|
1086
|
+
keyName,
|
|
1087
|
+
topicName,
|
|
1088
|
+
className,
|
|
1089
|
+
keyFilter,
|
|
1090
|
+
topicFilter,
|
|
1091
|
+
excludeKeyFilter,
|
|
1092
|
+
excludeTopicFilter,
|
|
1093
|
+
null,
|
|
1094
|
+
false,
|
|
1095
|
+
false,
|
|
1096
|
+
null,
|
|
1097
|
+
null,
|
|
1098
|
+
null,
|
|
1099
|
+
subscriptionGroup,
|
|
1100
|
+
null,
|
|
1101
|
+
snapshotSizeLimit
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Introduce a new key if not already known and return local key ID.
|
|
1107
|
+
* @param {string} name - Key name
|
|
1108
|
+
* @param {string|Array<string>|null} [className=null] - Class name(s)
|
|
1109
|
+
* @returns {number} Local key ID
|
|
1110
|
+
*/
|
|
1111
|
+
getAndPossiblyIntroduceKeyId(name, className = null) {
|
|
1112
|
+
if (!this.localKeyMap.has(name)) {
|
|
1113
|
+
const keyId = this.localKeyCounter++;
|
|
1114
|
+
this.localKeyMap.set(name, keyId);
|
|
1115
|
+
let classList = null;
|
|
1116
|
+
if (className) {
|
|
1117
|
+
if (typeof className === 'string') {
|
|
1118
|
+
classList = className.split(/\s+/).filter(Boolean);
|
|
1119
|
+
} else if (Array.isArray(className)) {
|
|
1120
|
+
classList = className;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const msg = {
|
|
1124
|
+
message_type: 'KeyIntroduction',
|
|
1125
|
+
value: {
|
|
1126
|
+
key_id: keyId,
|
|
1127
|
+
name
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
1130
|
+
if (classList) {
|
|
1131
|
+
msg.value.class_list = classList;
|
|
1132
|
+
}
|
|
1133
|
+
this.sendMessage(msg);
|
|
1134
|
+
}
|
|
1135
|
+
return this.localKeyMap.get(name);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Introduce a new topic if not already known and return local topic ID.
|
|
1140
|
+
* @param {string} name - Topic name
|
|
1141
|
+
* @returns {number} Local topic ID
|
|
1142
|
+
*/
|
|
1143
|
+
getAndPossiblyIntroduceTopicId(name) {
|
|
1144
|
+
if (!this.localTopicMap.has(name)) {
|
|
1145
|
+
const topicId = this.localTopicCounter++;
|
|
1146
|
+
this.localTopicMap.set(name, topicId);
|
|
1147
|
+
const msg = {
|
|
1148
|
+
message_type: 'TopicIntroduction',
|
|
1149
|
+
value: {
|
|
1150
|
+
topic_id: topicId,
|
|
1151
|
+
name
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
this.sendMessage(msg);
|
|
1155
|
+
}
|
|
1156
|
+
return this.localTopicMap.get(name);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Publish a DeleteKey message using a local key ID.
|
|
1161
|
+
* @param {number} keyId - Key ID
|
|
1162
|
+
*/
|
|
1163
|
+
publishDeleteKey(keyId) {
|
|
1164
|
+
const msg = {
|
|
1165
|
+
message_type: 'DeleteKey',
|
|
1166
|
+
value: { key_id: keyId }
|
|
1167
|
+
};
|
|
1168
|
+
this.sendMessage(msg);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Delete a key by name if it exists in the localKeyMap. Safe to call multiple times.
|
|
1173
|
+
* @param {string} keyName - Key name
|
|
1174
|
+
*/
|
|
1175
|
+
deleteKey(keyName) {
|
|
1176
|
+
const keyId = this.localKeyMap.get(keyName);
|
|
1177
|
+
if (keyId) {
|
|
1178
|
+
this.publishDeleteKey(keyId);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Publish a DeleteRecord message using local key and topic IDs.
|
|
1184
|
+
* @param {number} keyId - Key ID
|
|
1185
|
+
* @param {number} topicId - Topic ID
|
|
1186
|
+
*/
|
|
1187
|
+
publishDeleteRecord(keyId, topicId) {
|
|
1188
|
+
const msg = {
|
|
1189
|
+
message_type: 'DeleteRecord',
|
|
1190
|
+
value: { key_id: keyId, topic_id: topicId }
|
|
1191
|
+
};
|
|
1192
|
+
this.sendMessage(msg);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Publish an Unsubscribe message for a subscription name.
|
|
1197
|
+
* @param {string} name - Subscription name
|
|
1198
|
+
*/
|
|
1199
|
+
publishUnsubscribe(name) {
|
|
1200
|
+
const msg = {
|
|
1201
|
+
message_type: 'Unsubscribe',
|
|
1202
|
+
value: { name }
|
|
1203
|
+
};
|
|
1204
|
+
this.sendMessage(msg);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Publish a Shutdown message.
|
|
1209
|
+
*/
|
|
1210
|
+
publishShutdown() {
|
|
1211
|
+
const msg = { message_type: 'Shutdown' };
|
|
1212
|
+
this.sendMessage(msg);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Publish a record update using explicit key and topic IDs.
|
|
1217
|
+
* @param {number} keyId - Key ID
|
|
1218
|
+
* @param {number} topicId - Topic ID
|
|
1219
|
+
* @param {any} value - JSON-serializable value
|
|
1220
|
+
*/
|
|
1221
|
+
publishRecordWithIds(keyId, topicId, value) {
|
|
1222
|
+
const updateMsg = {
|
|
1223
|
+
message_type: 'JSONRecordUpdate',
|
|
1224
|
+
value: {
|
|
1225
|
+
record_id: { key_id: keyId, topic_id: topicId },
|
|
1226
|
+
value
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
this.sendMessage(updateMsg);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Publish a record update using names, converting to local IDs.
|
|
1234
|
+
* @param {string} keyName - Key name
|
|
1235
|
+
* @param {string} topicName - Topic name
|
|
1236
|
+
* @param {any} value - JSON-serializable value
|
|
1237
|
+
* @param {string|null} [className=null] - Class name
|
|
1238
|
+
*/
|
|
1239
|
+
publishRecord(keyName, topicName, value, className = null) {
|
|
1240
|
+
const keyId = this.getAndPossiblyIntroduceKeyId(keyName, className);
|
|
1241
|
+
const topicId = this.getAndPossiblyIntroduceTopicId(topicName);
|
|
1242
|
+
this.publishRecordWithIds(keyId, topicId, value);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Send a compare-exchange using explicit key and topic IDs.
|
|
1247
|
+
* @param {number} keyId - Key ID
|
|
1248
|
+
* @param {number} topicId - Topic ID
|
|
1249
|
+
* @param {any} test - Expected current value (null = expect missing/empty)
|
|
1250
|
+
* @param {any} value - New value to set on match
|
|
1251
|
+
*/
|
|
1252
|
+
compareExchangeRecordWithIds(keyId, topicId, test, value) {
|
|
1253
|
+
this.sendMessage({
|
|
1254
|
+
message_type: 'JSONCompareExchange',
|
|
1255
|
+
value: { record_id: { key_id: keyId, topic_id: topicId }, test, value }
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Send a compare-exchange using names, converting to local IDs.
|
|
1261
|
+
* @param {string} keyName - Key name
|
|
1262
|
+
* @param {string} topicName - Topic name
|
|
1263
|
+
* @param {any} test - Expected current value (null = expect missing/empty)
|
|
1264
|
+
* @param {any} value - New value to set on match
|
|
1265
|
+
* @param {string|null} [className=null] - Class name
|
|
1266
|
+
*/
|
|
1267
|
+
compareExchangeRecord(keyName, topicName, test, value, className = null) {
|
|
1268
|
+
const keyId = this.getAndPossiblyIntroduceKeyId(keyName, className);
|
|
1269
|
+
const topicId = this.getAndPossiblyIntroduceTopicId(topicName);
|
|
1270
|
+
this.compareExchangeRecordWithIds(keyId, topicId, test, value);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Query GAR deployment routing to find servers with matching publications.
|
|
1275
|
+
* Mirrors the Python client's route_query implementation.
|
|
1276
|
+
*
|
|
1277
|
+
* @param {Array<string>|null} [keyList]
|
|
1278
|
+
* @param {Array<string>|null} [topicList]
|
|
1279
|
+
* @param {Array<string>|null} [classList]
|
|
1280
|
+
* @param {string|null} [keyFilter]
|
|
1281
|
+
* @param {string|null} [excludeKeyFilter]
|
|
1282
|
+
* @param {string|null} [topicFilter]
|
|
1283
|
+
* @param {string|null} [excludeTopicFilter]
|
|
1284
|
+
* @param {Array<string>|null} [historyTypes]
|
|
1285
|
+
* @param {string|null} [workingNamespace]
|
|
1286
|
+
* @param {string|null} [restrictNamespace]
|
|
1287
|
+
* @param {string|null} [excludeNamespace]
|
|
1288
|
+
* @param {boolean} [includeDerived=false]
|
|
1289
|
+
* @param {number} [timeout=10.0] seconds
|
|
1290
|
+
* @param {number} [subscriptionGroupServerKeys=650704]
|
|
1291
|
+
* @param {number} [subscriptionGroupServerRecords=650705]
|
|
1292
|
+
* @returns {Promise<Object|null>} Resolves to a map of server -> topic -> value, or null on timeout
|
|
1293
|
+
*/
|
|
1294
|
+
route_query(
|
|
1295
|
+
keyList = null,
|
|
1296
|
+
topicList = null,
|
|
1297
|
+
classList = null,
|
|
1298
|
+
keyFilter = null,
|
|
1299
|
+
excludeKeyFilter = null,
|
|
1300
|
+
topicFilter = null,
|
|
1301
|
+
excludeTopicFilter = null,
|
|
1302
|
+
historyTypes = null,
|
|
1303
|
+
workingNamespace = null,
|
|
1304
|
+
restrictNamespace = null,
|
|
1305
|
+
excludeNamespace = null,
|
|
1306
|
+
includeDerived = false,
|
|
1307
|
+
timeout = 10.0,
|
|
1308
|
+
subscriptionGroupServerKeys = 650704,
|
|
1309
|
+
subscriptionGroupServerRecords = 650705,
|
|
1310
|
+
) {
|
|
1311
|
+
// Generate a unique key for this query
|
|
1312
|
+
const queryKey = (typeof crypto !== 'undefined' && crypto.randomUUID)
|
|
1313
|
+
? crypto.randomUUID()
|
|
1314
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1315
|
+
|
|
1316
|
+
// Build record_filter value and remove null/undefined
|
|
1317
|
+
const recordFilter = {};
|
|
1318
|
+
if (Array.isArray(keyList)) recordFilter.key_list = keyList;
|
|
1319
|
+
if (Array.isArray(topicList)) recordFilter.topic_list = topicList;
|
|
1320
|
+
if (Array.isArray(classList)) recordFilter.class_list = classList;
|
|
1321
|
+
if (workingNamespace != null) recordFilter.working_namespace = workingNamespace;
|
|
1322
|
+
if (restrictNamespace != null) recordFilter.restrict_namespace = restrictNamespace;
|
|
1323
|
+
if (excludeNamespace != null) recordFilter.exclude_namespace = excludeNamespace;
|
|
1324
|
+
if (keyFilter != null) recordFilter.key_filter_regex = keyFilter;
|
|
1325
|
+
if (excludeKeyFilter != null) recordFilter.exclude_key_filter_regex = excludeKeyFilter;
|
|
1326
|
+
if (topicFilter != null) recordFilter.topic_filter_regex = topicFilter;
|
|
1327
|
+
if (excludeTopicFilter != null) recordFilter.exclude_topic_filter_regex = excludeTopicFilter;
|
|
1328
|
+
if (includeDerived) recordFilter.include_derived = includeDerived;
|
|
1329
|
+
if (Array.isArray(historyTypes)) recordFilter.history_types = historyTypes;
|
|
1330
|
+
|
|
1331
|
+
const resultContainer = { };
|
|
1332
|
+
|
|
1333
|
+
const cleanup = () => {
|
|
1334
|
+
this.deleteKey(queryKey);
|
|
1335
|
+
};
|
|
1336
|
+
|
|
1337
|
+
return new Promise((resolve) => {
|
|
1338
|
+
let resolved = false;
|
|
1339
|
+
const finish = (result) => {
|
|
1340
|
+
if (!resolved) {
|
|
1341
|
+
resolved = true;
|
|
1342
|
+
resolve(result);
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1346
|
+
const handleServers = (serversValue) => {
|
|
1347
|
+
if (!serversValue || serversValue.length === 0) {
|
|
1348
|
+
resultContainer.servers = {};
|
|
1349
|
+
finish({});
|
|
1350
|
+
cleanup();
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// Subscribe to Server records for the returned server keys
|
|
1355
|
+
const subName = `route_query_servers_${queryKey}`;
|
|
1356
|
+
|
|
1357
|
+
const processServerRecords = (keyId, topicId, value) => {
|
|
1358
|
+
const keyName = this.serverKeyIdToName.get(keyId);
|
|
1359
|
+
const topicName = this.serverTopicIdToName.get(topicId);
|
|
1360
|
+
if (!resultContainer.servers) resultContainer.servers = {};
|
|
1361
|
+
if (!resultContainer.servers[keyName]) resultContainer.servers[keyName] = {};
|
|
1362
|
+
resultContainer.servers[keyName][topicName] = value;
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
const onServerStatus = (_name, status) => {
|
|
1366
|
+
if (status === 'Finished' || status === 'Streaming') {
|
|
1367
|
+
finish(resultContainer.servers || {});
|
|
1368
|
+
cleanup();
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
this.registerRecordUpdateHandler(processServerRecords, subscriptionGroupServerRecords);
|
|
1373
|
+
this.registerSubscriptionStatusHandler(onServerStatus, subscriptionGroupServerRecords);
|
|
1374
|
+
|
|
1375
|
+
this.subscribe(
|
|
1376
|
+
subName,
|
|
1377
|
+
'Snapshot',
|
|
1378
|
+
serversValue,
|
|
1379
|
+
null,
|
|
1380
|
+
'g::deployment::Server',
|
|
1381
|
+
null,
|
|
1382
|
+
null,
|
|
1383
|
+
null,
|
|
1384
|
+
null,
|
|
1385
|
+
null,
|
|
1386
|
+
true,
|
|
1387
|
+
false,
|
|
1388
|
+
'g::deployment::Server',
|
|
1389
|
+
null,
|
|
1390
|
+
null,
|
|
1391
|
+
subscriptionGroupServerRecords,
|
|
1392
|
+
);
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
const processRecordFilter = (_keyId, _topicId, value) => {
|
|
1396
|
+
resultContainer.servers = {};
|
|
1397
|
+
handleServers(value);
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
const onRecordFilterStatus = (_name, status) => {
|
|
1401
|
+
if (status === 'Finished' || status === 'Streaming') {
|
|
1402
|
+
if (!('servers' in resultContainer)) {
|
|
1403
|
+
finish({});
|
|
1404
|
+
cleanup();
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
};
|
|
1408
|
+
|
|
1409
|
+
// Set up handlers for RecordFilter result
|
|
1410
|
+
this.registerRecordUpdateHandler(processRecordFilter, subscriptionGroupServerKeys);
|
|
1411
|
+
this.registerSubscriptionStatusHandler(onRecordFilterStatus, subscriptionGroupServerKeys);
|
|
1412
|
+
|
|
1413
|
+
// Publish the RecordFilter record
|
|
1414
|
+
this.publishRecord(
|
|
1415
|
+
queryKey,
|
|
1416
|
+
'g::deployment::RecordFilter::record_filter',
|
|
1417
|
+
recordFilter,
|
|
1418
|
+
'g::deployment::RecordFilter',
|
|
1419
|
+
);
|
|
1420
|
+
|
|
1421
|
+
// Subscribe to get the servers result
|
|
1422
|
+
const subName = `route_query_${queryKey}`;
|
|
1423
|
+
this.subscribe(
|
|
1424
|
+
subName,
|
|
1425
|
+
'Snapshot',
|
|
1426
|
+
queryKey,
|
|
1427
|
+
'g::deployment::RecordFilter::servers',
|
|
1428
|
+
'g::deployment::RecordFilter',
|
|
1429
|
+
null,
|
|
1430
|
+
null,
|
|
1431
|
+
null,
|
|
1432
|
+
null,
|
|
1433
|
+
null,
|
|
1434
|
+
true,
|
|
1435
|
+
false,
|
|
1436
|
+
'g::deployment::RecordFilter',
|
|
1437
|
+
null,
|
|
1438
|
+
null,
|
|
1439
|
+
subscriptionGroupServerKeys,
|
|
1440
|
+
);
|
|
1441
|
+
|
|
1442
|
+
// Timeout handling
|
|
1443
|
+
setTimeout(() => {
|
|
1444
|
+
if (!resolved) {
|
|
1445
|
+
this.log('ERROR', 'route_query timed out');
|
|
1446
|
+
cleanup();
|
|
1447
|
+
finish(null);
|
|
1448
|
+
}
|
|
1449
|
+
}, Math.max(0, Math.floor(timeout * 1000)));
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
static _generateUUID() {
|
|
1454
|
+
const bytes = new Uint8Array(16);
|
|
1455
|
+
if (typeof globalThis.crypto !== 'undefined' && globalThis.crypto.getRandomValues)
|
|
1456
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
1457
|
+
else
|
|
1458
|
+
require('crypto').randomFillSync(bytes);
|
|
1459
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
|
1460
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant RFC 4122
|
|
1461
|
+
const view = new DataView(bytes.buffer);
|
|
1462
|
+
return { low: Number(view.getBigUint64(0, true)), high: Number(view.getBigUint64(8, true)) };
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
export default GARClient;
|