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.
Files changed (2) hide show
  1. package/gar.js +1466 -0
  2. 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;