jsgar 3.4.1 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/gar.umd.js +296 -38
  2. package/package.json +4 -4
package/dist/gar.umd.js CHANGED
@@ -25,12 +25,10 @@
25
25
  * protocol specifications and usage instructions.
26
26
  */
27
27
 
28
- //for browser:
29
- const WebSocket = window.WebSocket;
30
-
31
- // for Node.js:
32
- // import ws from 'ws';
33
- // const WebSocket = ws;
28
+ // WebSocket environment shim:
29
+ // - In browsers, uses window.WebSocket
30
+ // - In Node.js, dynamically imports 'ws' and assigns globalThis.WebSocket
31
+ // All client code references the constructor via helper methods, not a top-level binding.
34
32
 
35
33
  class GARClient {
36
34
  /**
@@ -90,6 +88,11 @@
90
88
  }
91
89
 
92
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();
93
96
  }
94
97
 
95
98
  /**
@@ -135,6 +138,9 @@
135
138
  this.recordMap.clear();
136
139
 
137
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.
138
144
  }
139
145
 
140
146
  /**
@@ -149,20 +155,13 @@
149
155
 
150
156
  while (this.running && !this.connected) {
151
157
  try {
152
- if (this.allowSelfSignedCertificate) {
153
- const isNode =
154
- typeof process !== "undefined" &&
155
- process.versions != null &&
156
- process.versions.node != null;
157
- console.assert(isNode, "Self-signed certificates are not supported in the browser.");
158
- this.websocket = new WebSocket(
159
- this.wsEndpoint,
160
- ['gar-protocol'],
161
- { rejectUnauthorized: false }
162
- );
163
- }
164
- else {
165
- this.websocket = new WebSocket(this.wsEndpoint, ['gar-protocol']);
158
+ const WS = await this._ensureWebSocketCtor();
159
+ const isNode = typeof process !== 'undefined' && process.versions && process.versions.node;
160
+ if (this.allowSelfSignedCertificate && isNode) {
161
+ // Node.js 'ws' supports options for TLS; browsers do not.
162
+ this.websocket = new WS(this.wsEndpoint, ['gar-protocol'], { rejectUnauthorized: false });
163
+ } else {
164
+ this.websocket = new WS(this.wsEndpoint, ['gar-protocol']);
166
165
  }
167
166
  this.websocket.onopen = () => {
168
167
  this.connected = true;
@@ -212,7 +211,7 @@
212
211
  try {
213
212
  const message = this.messageQueue.shift();
214
213
  if (message === null) break;
215
- if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
214
+ if (this._isSocketOpen()) {
216
215
  this.websocket.send(JSON.stringify(message));
217
216
  this.log('DEBUG', `Sent: ${JSON.stringify(message)}`);
218
217
  }
@@ -340,13 +339,19 @@
340
339
  /**
341
340
  * Register handler for SubscriptionStatus message.
342
341
  * @param {Function} handler - Callback with (name, status)
342
+ * @param {number} [subscriptionGroup=0] - The subscription group for callback
343
343
  */
344
- registerSubscriptionStatusHandler(handler) {
345
- this.registerHandler('SubscriptionStatus', (msg) => handler(msg.value.name, msg.value.status));
344
+ registerSubscriptionStatusHandler(handler, subscriptionGroup = 0) {
345
+ this.registerHandler('SubscriptionStatus', (msg) => handler(msg.value.name, msg.value.status), subscriptionGroup);
346
346
  }
347
347
 
348
- clearSubscriptionStatusHandler() {
349
- this.messageHandlers.delete('SubscriptionStatus');
348
+ /**
349
+ * Clear handler for SubscriptionStatus message.
350
+ * @param {number} [subscriptionGroup=0] - The subscription group to clear
351
+ */
352
+ clearSubscriptionStatusHandler(subscriptionGroup = 0) {
353
+ const key = subscriptionGroup ? `SubscriptionStatus ${subscriptionGroup}` : 'SubscriptionStatus';
354
+ this.messageHandlers.delete(key);
350
355
  }
351
356
 
352
357
  /**
@@ -495,7 +500,7 @@
495
500
  this.messageQueue = [];
496
501
  }
497
502
 
498
- if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
503
+ if (this.websocket && this.websocket.readyState === 1) {
499
504
  this.websocket.close();
500
505
  }
501
506
  this.messageQueue = [];
@@ -505,6 +510,35 @@
505
510
  this.log('INFO', 'GAR client stopped');
506
511
  }
507
512
 
513
+ _isSocketOpen() {
514
+ // Both browser and 'ws' use readyState 1 for OPEN
515
+ return !!(this.websocket && this.websocket.readyState === 1);
516
+ }
517
+
518
+ async _ensureWebSocketCtor() {
519
+ // Return a WebSocket constructor for current environment.
520
+ // Prefer any existing globalThis.WebSocket (browser or pre-set in Node).
521
+ if (typeof globalThis !== 'undefined' && globalThis.WebSocket) {
522
+ return globalThis.WebSocket;
523
+ }
524
+ // Node.js: resolve 'ws' at runtime without using dynamic import (avoids rollup code-splitting)
525
+ const isNode = typeof process !== 'undefined' && process.versions && process.versions.node;
526
+ if (isNode) {
527
+ try {
528
+ const req = (typeof module !== 'undefined' && module.require)
529
+ ? module.require.bind(module)
530
+ : (typeof require !== 'undefined' ? require : Function('return require')());
531
+ const mod = req('ws');
532
+ const WS = mod.default || mod.WebSocket || mod;
533
+ globalThis.WebSocket = WS;
534
+ return WS;
535
+ } catch {
536
+ throw new Error("'ws' module is required in Node.js environment. Please install it: npm install ws");
537
+ }
538
+ }
539
+ throw new Error('WebSocket constructor not found in this environment');
540
+ }
541
+
508
542
  /**
509
543
  * Stop the client without sending pending messages.
510
544
  */
@@ -567,6 +601,49 @@
567
601
  }
568
602
  }
569
603
 
604
+ /**
605
+ * Reset the internal first-heartbeat latch and create a fresh Promise.
606
+ * Called on construction, clearConnectionState, and Introduction.
607
+ * @private
608
+ */
609
+ _resetFirstHeartbeatLatch() {
610
+ let resolved = false;
611
+ this._firstHeartbeatPromise = new Promise((resolve) => {
612
+ this._firstHeartbeatResolve = (value) => {
613
+ if (!resolved) {
614
+ resolved = true;
615
+ resolve(value);
616
+ }
617
+ };
618
+ });
619
+ }
620
+
621
+ /**
622
+ * Wait for the first heartbeat after the most recent Introduction.
623
+ * @param {number} [timeoutMs=10000] - Timeout in milliseconds
624
+ * @returns {Promise<void>} Resolves on first heartbeat; rejects on timeout
625
+ */
626
+ awaitFirstHeartbeat(timeoutMs = 10000) {
627
+ const to = Math.max(0, Math.floor(timeoutMs));
628
+ return new Promise((resolve, reject) => {
629
+ let timer = null;
630
+ if (to > 0) {
631
+ timer = setTimeout(() => {
632
+ reject(new Error('Timed out waiting for first heartbeat'));
633
+ }, to);
634
+ }
635
+ this._firstHeartbeatPromise
636
+ .then(() => {
637
+ if (timer) clearTimeout(timer);
638
+ resolve();
639
+ })
640
+ .catch((e) => {
641
+ if (timer) clearTimeout(timer);
642
+ reject(e);
643
+ });
644
+ });
645
+ }
646
+
570
647
  /**
571
648
  * Process incoming messages by calling registered handlers.
572
649
  * @param {Object} message - Incoming message
@@ -588,6 +665,10 @@
588
665
  } else if (msgType === 'Heartbeat') {
589
666
  this.lastHeartbeatTime = Date.now() / 1000;
590
667
  if (this._initialGracePeriod) {
668
+ // Resolve first-heartbeat waiters and end the initial grace window
669
+ if (this._firstHeartbeatResolve) {
670
+ try { this._firstHeartbeatResolve(); } catch { /* noop */ }
671
+ }
591
672
  this._initialGracePeriod = false;
592
673
  }
593
674
  } else if (msgType === 'Introduction') {
@@ -601,12 +682,18 @@
601
682
  this.lastHeartbeatTime = Date.now() / 1000;
602
683
  this._initialGracePeriod = true;
603
684
  this._initialGraceDeadline = this.lastHeartbeatTime + this.heartbeatTimeout * 10;
685
+ // New Introduction: do not reset the first-heartbeat promise to avoid racing
686
+ // with consumers already awaiting it. The existing promise will resolve on
687
+ // the first Heartbeat observed during the grace period above.
604
688
  } else if (msgType === 'JSONRecordUpdate') {
605
689
  subscriptionGroup = this.activeSubscriptionGroup;
606
690
  const recordId = message.value.record_id;
607
691
  const keyId = recordId.key_id;
608
692
  const topicId = recordId.topic_id;
609
693
  this.recordMap.set(`${keyId}:${topicId}`, message.value.value);
694
+ } else if (msgType === 'SubscriptionStatus') {
695
+ // Route status notifications to the currently active subscription group
696
+ subscriptionGroup = this.activeSubscriptionGroup;
610
697
  } else if (msgType === 'DeleteRecord') {
611
698
  subscriptionGroup = this.activeSubscriptionGroup;
612
699
  const {key_id: keyId, topic_id: topicId} = message.value;
@@ -733,8 +820,6 @@
733
820
  * @param {string|null} [excludeKeyFilter=null] - Exclude key filter regex (cannot use with keyName)
734
821
  * @param {string|null} [excludeTopicFilter=null] - Exclude topic filter regex (cannot use with topicName)
735
822
  * @param {string|null} [maxHistory] - Maximum history to include
736
- * @param {boolean} [includeReferencedKeys=false] - Add keys from key references in matched records
737
- * @param {boolean} [includeReferencingKeys=false] - Add keys that have one or more records referencing any matched keys
738
823
  * @param {boolean} [includeDerived=false] - Include derived topics
739
824
  * @param {boolean} [trimDefaultValues=false] - Trim records containing default values from the snapshot
740
825
  * @param {string|null} [workingNamespace] - Namespace for matching relative paths
@@ -757,8 +842,6 @@
757
842
  excludeKeyFilter = null,
758
843
  excludeTopicFilter = null,
759
844
  maxHistory = null,
760
- includeReferencedKeys = false,
761
- includeReferencingKeys = false,
762
845
  includeDerived = false,
763
846
  trimDefaultValues = false,
764
847
  workingNamespace = null,
@@ -837,12 +920,6 @@
837
920
  if (density) {
838
921
  valueDict.density = density;
839
922
  }
840
- if (includeReferencedKeys) {
841
- valueDict.include_referenced_keys = includeReferencedKeys;
842
- }
843
- if (includeReferencingKeys) {
844
- valueDict.include_referencing_keys = includeReferencingKeys;
845
- }
846
923
  if (includeDerived) {
847
924
  valueDict.include_derived = includeDerived;
848
925
  }
@@ -923,8 +1000,6 @@
923
1000
  null,
924
1001
  false,
925
1002
  false,
926
- false,
927
- false,
928
1003
  null,
929
1004
  null,
930
1005
  null,
@@ -1062,6 +1137,189 @@
1062
1137
  const topicId = this.getAndPossiblyIntroduceTopicId(topicName);
1063
1138
  this.publishRecordWithIds(keyId, topicId, value);
1064
1139
  }
1140
+
1141
+ /**
1142
+ * Query GAR deployment routing to find servers with matching publications.
1143
+ * Mirrors the Python client's route_query implementation.
1144
+ *
1145
+ * @param {Array<string>|null} [keyList]
1146
+ * @param {Array<string>|null} [topicList]
1147
+ * @param {Array<string>|null} [classList]
1148
+ * @param {string|null} [keyFilter]
1149
+ * @param {string|null} [excludeKeyFilter]
1150
+ * @param {string|null} [topicFilter]
1151
+ * @param {string|null} [excludeTopicFilter]
1152
+ * @param {Array<string>|null} [historyTypes]
1153
+ * @param {string|null} [workingNamespace]
1154
+ * @param {string|null} [restrictNamespace]
1155
+ * @param {string|null} [excludeNamespace]
1156
+ * @param {boolean} [includeDerived=false]
1157
+ * @param {number} [timeout=10.0] seconds
1158
+ * @param {number} [subscriptionGroupServerKeys=650704]
1159
+ * @param {number} [subscriptionGroupServerRecords=650705]
1160
+ * @returns {Promise<Object|null>} Resolves to a map of server -> topic -> value, or null on timeout
1161
+ */
1162
+ route_query(
1163
+ keyList = null,
1164
+ topicList = null,
1165
+ classList = null,
1166
+ keyFilter = null,
1167
+ excludeKeyFilter = null,
1168
+ topicFilter = null,
1169
+ excludeTopicFilter = null,
1170
+ historyTypes = null,
1171
+ workingNamespace = null,
1172
+ restrictNamespace = null,
1173
+ excludeNamespace = null,
1174
+ includeDerived = false,
1175
+ timeout = 10.0,
1176
+ subscriptionGroupServerKeys = 650704,
1177
+ subscriptionGroupServerRecords = 650705,
1178
+ ) {
1179
+ // Generate a unique key for this query
1180
+ const queryKey = (typeof crypto !== 'undefined' && crypto.randomUUID)
1181
+ ? crypto.randomUUID()
1182
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1183
+
1184
+ // Build record_filter value and remove null/undefined
1185
+ const recordFilter = {};
1186
+ if (Array.isArray(keyList)) recordFilter.key_list = keyList;
1187
+ if (Array.isArray(topicList)) recordFilter.topic_list = topicList;
1188
+ if (Array.isArray(classList)) recordFilter.class_list = classList;
1189
+ if (workingNamespace != null) recordFilter.working_namespace = workingNamespace;
1190
+ if (restrictNamespace != null) recordFilter.restrict_namespace = restrictNamespace;
1191
+ if (excludeNamespace != null) recordFilter.exclude_namespace = excludeNamespace;
1192
+ if (keyFilter != null) recordFilter.key_filter_regex = keyFilter;
1193
+ if (excludeKeyFilter != null) recordFilter.exclude_key_filter_regex = excludeKeyFilter;
1194
+ if (topicFilter != null) recordFilter.topic_filter_regex = topicFilter;
1195
+ if (excludeTopicFilter != null) recordFilter.exclude_topic_filter_regex = excludeTopicFilter;
1196
+ if (includeDerived) recordFilter.include_derived = includeDerived;
1197
+ if (Array.isArray(historyTypes)) recordFilter.history_types = historyTypes;
1198
+
1199
+ const resultContainer = { };
1200
+
1201
+ const cleanup = () => {
1202
+ const keyId = this.localKeyMap.get(queryKey);
1203
+ if (keyId) {
1204
+ this.publishDeleteKey(keyId);
1205
+ }
1206
+ };
1207
+
1208
+ return new Promise((resolve) => {
1209
+ let resolved = false;
1210
+ const finish = (result) => {
1211
+ if (!resolved) {
1212
+ resolved = true;
1213
+ resolve(result);
1214
+ }
1215
+ };
1216
+
1217
+ const handleServers = (serversValue) => {
1218
+ if (!serversValue || serversValue.length === 0) {
1219
+ resultContainer.servers = {};
1220
+ finish({});
1221
+ cleanup();
1222
+ return;
1223
+ }
1224
+
1225
+ // Subscribe to Server records for the returned server keys
1226
+ const subName = `route_query_servers_${queryKey}`;
1227
+
1228
+ const processServerRecords = (keyId, topicId, value) => {
1229
+ const keyName = this.serverKeyIdToName.get(keyId);
1230
+ const topicName = this.serverTopicIdToName.get(topicId);
1231
+ if (!resultContainer.servers) resultContainer.servers = {};
1232
+ if (!resultContainer.servers[keyName]) resultContainer.servers[keyName] = {};
1233
+ resultContainer.servers[keyName][topicName] = value;
1234
+ };
1235
+
1236
+ const onServerStatus = (_name, status) => {
1237
+ if (status === 'Finished' || status === 'Streaming') {
1238
+ finish(resultContainer.servers || {});
1239
+ cleanup();
1240
+ }
1241
+ };
1242
+
1243
+ this.registerRecordUpdateHandler(processServerRecords, subscriptionGroupServerRecords);
1244
+ this.registerSubscriptionStatusHandler(onServerStatus, subscriptionGroupServerRecords);
1245
+
1246
+ this.subscribe(
1247
+ subName,
1248
+ 'Snapshot',
1249
+ serversValue,
1250
+ null,
1251
+ 'g::deployment::Server',
1252
+ null,
1253
+ null,
1254
+ null,
1255
+ null,
1256
+ null,
1257
+ true,
1258
+ false,
1259
+ 'g::deployment::Server',
1260
+ null,
1261
+ null,
1262
+ subscriptionGroupServerRecords,
1263
+ );
1264
+ };
1265
+
1266
+ const processRecordFilter = (_keyId, _topicId, value) => {
1267
+ resultContainer.servers = {};
1268
+ handleServers(value);
1269
+ };
1270
+
1271
+ const onRecordFilterStatus = (_name, status) => {
1272
+ if (status === 'Finished' || status === 'Streaming') {
1273
+ if (!('servers' in resultContainer)) {
1274
+ finish({});
1275
+ cleanup();
1276
+ }
1277
+ }
1278
+ };
1279
+
1280
+ // Set up handlers for RecordFilter result
1281
+ this.registerRecordUpdateHandler(processRecordFilter, subscriptionGroupServerKeys);
1282
+ this.registerSubscriptionStatusHandler(onRecordFilterStatus, subscriptionGroupServerKeys);
1283
+
1284
+ // Publish the RecordFilter record
1285
+ this.publishRecord(
1286
+ queryKey,
1287
+ 'g::deployment::RecordFilter::record_filter',
1288
+ recordFilter,
1289
+ 'g::deployment::RecordFilter',
1290
+ );
1291
+
1292
+ // Subscribe to get the servers result
1293
+ const subName = `route_query_${queryKey}`;
1294
+ this.subscribe(
1295
+ subName,
1296
+ 'Snapshot',
1297
+ queryKey,
1298
+ 'g::deployment::RecordFilter::servers',
1299
+ 'g::deployment::RecordFilter',
1300
+ null,
1301
+ null,
1302
+ null,
1303
+ null,
1304
+ null,
1305
+ true,
1306
+ false,
1307
+ 'g::deployment::RecordFilter',
1308
+ null,
1309
+ null,
1310
+ subscriptionGroupServerKeys,
1311
+ );
1312
+
1313
+ // Timeout handling
1314
+ setTimeout(() => {
1315
+ if (!resolved) {
1316
+ this.log('ERROR', 'route_query timed out');
1317
+ cleanup();
1318
+ finish(null);
1319
+ }
1320
+ }, Math.max(0, Math.floor(timeout * 1000)));
1321
+ });
1322
+ }
1065
1323
  }
1066
1324
 
1067
1325
  return GARClient;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsgar",
3
- "version": "3.4.1",
3
+ "version": "3.7.1",
4
4
  "description": "A Javascript client for the GAR protocol",
5
5
  "type": "module",
6
6
  "main": "dist/gar.umd.js",
@@ -19,15 +19,15 @@
19
19
  "license": "MIT",
20
20
  "dependencies": {
21
21
  "winston": "^3.17.0",
22
- "ws": "^8.18.1",
22
+ "ws": "^8.18.3",
23
23
  "yargs": "^17.7.2"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@eslint/js": "^9.25.0",
27
- "@eslint/json": "^0.12.0",
27
+ "@eslint/json": "^0.13.2",
28
28
  "@rollup/plugin-commonjs": "^24.0.0",
29
29
  "@rollup/plugin-node-resolve": "^15.0.0",
30
- "eslint": "^9.33.0",
30
+ "eslint": "^9.38.0",
31
31
  "globals": "^16.0.0",
32
32
  "rollup": "^3.0.0"
33
33
  }