jsgar 3.5.0 → 3.7.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/dist/gar.umd.js +304 -26
- package/package.json +2 -2
package/dist/gar.umd.js
CHANGED
|
@@ -25,12 +25,10 @@
|
|
|
25
25
|
* protocol specifications and usage instructions.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
this.websocket = new
|
|
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.
|
|
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
|
-
|
|
349
|
-
|
|
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 ===
|
|
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;
|
|
@@ -988,6 +1075,17 @@
|
|
|
988
1075
|
this.sendMessage(msg);
|
|
989
1076
|
}
|
|
990
1077
|
|
|
1078
|
+
/**
|
|
1079
|
+
* Delete a key by name if it exists in the localKeyMap. Safe to call multiple times.
|
|
1080
|
+
* @param {string} keyName - Key name
|
|
1081
|
+
*/
|
|
1082
|
+
deleteKey(keyName) {
|
|
1083
|
+
const keyId = this.localKeyMap.get(keyName);
|
|
1084
|
+
if (keyId) {
|
|
1085
|
+
this.publishDeleteKey(keyId);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
991
1089
|
/**
|
|
992
1090
|
* Publish a DeleteRecord message using local key and topic IDs.
|
|
993
1091
|
* @param {number} keyId - Key ID
|
|
@@ -1050,6 +1148,186 @@
|
|
|
1050
1148
|
const topicId = this.getAndPossiblyIntroduceTopicId(topicName);
|
|
1051
1149
|
this.publishRecordWithIds(keyId, topicId, value);
|
|
1052
1150
|
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Query GAR deployment routing to find servers with matching publications.
|
|
1154
|
+
* Mirrors the Python client's route_query implementation.
|
|
1155
|
+
*
|
|
1156
|
+
* @param {Array<string>|null} [keyList]
|
|
1157
|
+
* @param {Array<string>|null} [topicList]
|
|
1158
|
+
* @param {Array<string>|null} [classList]
|
|
1159
|
+
* @param {string|null} [keyFilter]
|
|
1160
|
+
* @param {string|null} [excludeKeyFilter]
|
|
1161
|
+
* @param {string|null} [topicFilter]
|
|
1162
|
+
* @param {string|null} [excludeTopicFilter]
|
|
1163
|
+
* @param {Array<string>|null} [historyTypes]
|
|
1164
|
+
* @param {string|null} [workingNamespace]
|
|
1165
|
+
* @param {string|null} [restrictNamespace]
|
|
1166
|
+
* @param {string|null} [excludeNamespace]
|
|
1167
|
+
* @param {boolean} [includeDerived=false]
|
|
1168
|
+
* @param {number} [timeout=10.0] seconds
|
|
1169
|
+
* @param {number} [subscriptionGroupServerKeys=650704]
|
|
1170
|
+
* @param {number} [subscriptionGroupServerRecords=650705]
|
|
1171
|
+
* @returns {Promise<Object|null>} Resolves to a map of server -> topic -> value, or null on timeout
|
|
1172
|
+
*/
|
|
1173
|
+
route_query(
|
|
1174
|
+
keyList = null,
|
|
1175
|
+
topicList = null,
|
|
1176
|
+
classList = null,
|
|
1177
|
+
keyFilter = null,
|
|
1178
|
+
excludeKeyFilter = null,
|
|
1179
|
+
topicFilter = null,
|
|
1180
|
+
excludeTopicFilter = null,
|
|
1181
|
+
historyTypes = null,
|
|
1182
|
+
workingNamespace = null,
|
|
1183
|
+
restrictNamespace = null,
|
|
1184
|
+
excludeNamespace = null,
|
|
1185
|
+
includeDerived = false,
|
|
1186
|
+
timeout = 10.0,
|
|
1187
|
+
subscriptionGroupServerKeys = 650704,
|
|
1188
|
+
subscriptionGroupServerRecords = 650705,
|
|
1189
|
+
) {
|
|
1190
|
+
// Generate a unique key for this query
|
|
1191
|
+
const queryKey = (typeof crypto !== 'undefined' && crypto.randomUUID)
|
|
1192
|
+
? crypto.randomUUID()
|
|
1193
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1194
|
+
|
|
1195
|
+
// Build record_filter value and remove null/undefined
|
|
1196
|
+
const recordFilter = {};
|
|
1197
|
+
if (Array.isArray(keyList)) recordFilter.key_list = keyList;
|
|
1198
|
+
if (Array.isArray(topicList)) recordFilter.topic_list = topicList;
|
|
1199
|
+
if (Array.isArray(classList)) recordFilter.class_list = classList;
|
|
1200
|
+
if (workingNamespace != null) recordFilter.working_namespace = workingNamespace;
|
|
1201
|
+
if (restrictNamespace != null) recordFilter.restrict_namespace = restrictNamespace;
|
|
1202
|
+
if (excludeNamespace != null) recordFilter.exclude_namespace = excludeNamespace;
|
|
1203
|
+
if (keyFilter != null) recordFilter.key_filter_regex = keyFilter;
|
|
1204
|
+
if (excludeKeyFilter != null) recordFilter.exclude_key_filter_regex = excludeKeyFilter;
|
|
1205
|
+
if (topicFilter != null) recordFilter.topic_filter_regex = topicFilter;
|
|
1206
|
+
if (excludeTopicFilter != null) recordFilter.exclude_topic_filter_regex = excludeTopicFilter;
|
|
1207
|
+
if (includeDerived) recordFilter.include_derived = includeDerived;
|
|
1208
|
+
if (Array.isArray(historyTypes)) recordFilter.history_types = historyTypes;
|
|
1209
|
+
|
|
1210
|
+
const resultContainer = { };
|
|
1211
|
+
|
|
1212
|
+
const cleanup = () => {
|
|
1213
|
+
this.deleteKey(queryKey);
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
return new Promise((resolve) => {
|
|
1217
|
+
let resolved = false;
|
|
1218
|
+
const finish = (result) => {
|
|
1219
|
+
if (!resolved) {
|
|
1220
|
+
resolved = true;
|
|
1221
|
+
resolve(result);
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
const handleServers = (serversValue) => {
|
|
1226
|
+
if (!serversValue || serversValue.length === 0) {
|
|
1227
|
+
resultContainer.servers = {};
|
|
1228
|
+
finish({});
|
|
1229
|
+
cleanup();
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Subscribe to Server records for the returned server keys
|
|
1234
|
+
const subName = `route_query_servers_${queryKey}`;
|
|
1235
|
+
|
|
1236
|
+
const processServerRecords = (keyId, topicId, value) => {
|
|
1237
|
+
const keyName = this.serverKeyIdToName.get(keyId);
|
|
1238
|
+
const topicName = this.serverTopicIdToName.get(topicId);
|
|
1239
|
+
if (!resultContainer.servers) resultContainer.servers = {};
|
|
1240
|
+
if (!resultContainer.servers[keyName]) resultContainer.servers[keyName] = {};
|
|
1241
|
+
resultContainer.servers[keyName][topicName] = value;
|
|
1242
|
+
};
|
|
1243
|
+
|
|
1244
|
+
const onServerStatus = (_name, status) => {
|
|
1245
|
+
if (status === 'Finished' || status === 'Streaming') {
|
|
1246
|
+
finish(resultContainer.servers || {});
|
|
1247
|
+
cleanup();
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
this.registerRecordUpdateHandler(processServerRecords, subscriptionGroupServerRecords);
|
|
1252
|
+
this.registerSubscriptionStatusHandler(onServerStatus, subscriptionGroupServerRecords);
|
|
1253
|
+
|
|
1254
|
+
this.subscribe(
|
|
1255
|
+
subName,
|
|
1256
|
+
'Snapshot',
|
|
1257
|
+
serversValue,
|
|
1258
|
+
null,
|
|
1259
|
+
'g::deployment::Server',
|
|
1260
|
+
null,
|
|
1261
|
+
null,
|
|
1262
|
+
null,
|
|
1263
|
+
null,
|
|
1264
|
+
null,
|
|
1265
|
+
true,
|
|
1266
|
+
false,
|
|
1267
|
+
'g::deployment::Server',
|
|
1268
|
+
null,
|
|
1269
|
+
null,
|
|
1270
|
+
subscriptionGroupServerRecords,
|
|
1271
|
+
);
|
|
1272
|
+
};
|
|
1273
|
+
|
|
1274
|
+
const processRecordFilter = (_keyId, _topicId, value) => {
|
|
1275
|
+
resultContainer.servers = {};
|
|
1276
|
+
handleServers(value);
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
const onRecordFilterStatus = (_name, status) => {
|
|
1280
|
+
if (status === 'Finished' || status === 'Streaming') {
|
|
1281
|
+
if (!('servers' in resultContainer)) {
|
|
1282
|
+
finish({});
|
|
1283
|
+
cleanup();
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
// Set up handlers for RecordFilter result
|
|
1289
|
+
this.registerRecordUpdateHandler(processRecordFilter, subscriptionGroupServerKeys);
|
|
1290
|
+
this.registerSubscriptionStatusHandler(onRecordFilterStatus, subscriptionGroupServerKeys);
|
|
1291
|
+
|
|
1292
|
+
// Publish the RecordFilter record
|
|
1293
|
+
this.publishRecord(
|
|
1294
|
+
queryKey,
|
|
1295
|
+
'g::deployment::RecordFilter::record_filter',
|
|
1296
|
+
recordFilter,
|
|
1297
|
+
'g::deployment::RecordFilter',
|
|
1298
|
+
);
|
|
1299
|
+
|
|
1300
|
+
// Subscribe to get the servers result
|
|
1301
|
+
const subName = `route_query_${queryKey}`;
|
|
1302
|
+
this.subscribe(
|
|
1303
|
+
subName,
|
|
1304
|
+
'Snapshot',
|
|
1305
|
+
queryKey,
|
|
1306
|
+
'g::deployment::RecordFilter::servers',
|
|
1307
|
+
'g::deployment::RecordFilter',
|
|
1308
|
+
null,
|
|
1309
|
+
null,
|
|
1310
|
+
null,
|
|
1311
|
+
null,
|
|
1312
|
+
null,
|
|
1313
|
+
true,
|
|
1314
|
+
false,
|
|
1315
|
+
'g::deployment::RecordFilter',
|
|
1316
|
+
null,
|
|
1317
|
+
null,
|
|
1318
|
+
subscriptionGroupServerKeys,
|
|
1319
|
+
);
|
|
1320
|
+
|
|
1321
|
+
// Timeout handling
|
|
1322
|
+
setTimeout(() => {
|
|
1323
|
+
if (!resolved) {
|
|
1324
|
+
this.log('ERROR', 'route_query timed out');
|
|
1325
|
+
cleanup();
|
|
1326
|
+
finish(null);
|
|
1327
|
+
}
|
|
1328
|
+
}, Math.max(0, Math.floor(timeout * 1000)));
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1053
1331
|
}
|
|
1054
1332
|
|
|
1055
1333
|
return GARClient;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jsgar",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.2",
|
|
4
4
|
"description": "A Javascript client for the GAR protocol",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/gar.umd.js",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"license": "MIT",
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"winston": "^3.17.0",
|
|
22
|
-
"ws": "^8.18.
|
|
22
|
+
"ws": "^8.18.3",
|
|
23
23
|
"yargs": "^17.7.2"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|