elasticio-sailor-nodejs 3.0.0-dev4 → 3.0.0-dev6
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/config/local.json +8 -8
- package/lib/proxy-client.js +124 -164
- package/lib/sailor.js +1 -1
- package/mise.toml +2 -0
- package/package.json +1 -1
- package/run.js +9 -19
package/config/local.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"LOG_LEVEL": "trace",
|
|
3
|
-
"ELASTICIO_FLOW_ID": "
|
|
4
|
-
"ELASTICIO_EXEC_ID": "
|
|
3
|
+
"ELASTICIO_FLOW_ID": "692ee23d4ab5d34bb7321559",
|
|
4
|
+
"ELASTICIO_EXEC_ID": "69b26526c4796609ca0da12a",
|
|
5
5
|
"ELASTICIO_STEP_ID": "step_1",
|
|
6
|
-
"ELASTICIO_CONTAINER_ID": "
|
|
7
|
-
"ELASTICIO_WORKSPACE_ID": "
|
|
8
|
-
"ELASTICIO_USER_ID": "
|
|
9
|
-
"ELASTICIO_COMP_ID": "
|
|
6
|
+
"ELASTICIO_CONTAINER_ID": "69b26526c4796609ca0da129",
|
|
7
|
+
"ELASTICIO_WORKSPACE_ID": "69b26526c4796609ca0da128",
|
|
8
|
+
"ELASTICIO_USER_ID": "69b26526c4796609ca0da127",
|
|
9
|
+
"ELASTICIO_COMP_ID": "69b26526c4796609ca0da126",
|
|
10
10
|
"ELASTICIO_FUNCTION": "data_trigger",
|
|
11
11
|
"ELASTICIO_API_URI": "http://localhost:9000",
|
|
12
|
-
"ELASTICIO_API_USERNAME": "task-
|
|
13
|
-
"ELASTICIO_API_KEY": "
|
|
12
|
+
"ELASTICIO_API_USERNAME": "task-692ee23d4ab5d34bb7321559",
|
|
13
|
+
"ELASTICIO_API_KEY": "976ffec8-455b-494e-9478-2d66761f4040",
|
|
14
14
|
"ELASTICIO_MESSAGE_CRYPTO_IV": "0.03091345790184",
|
|
15
15
|
"ELASTICIO_MESSAGE_CRYPTO_PASSWORD": "password",
|
|
16
16
|
"ELASTICIO_SAILOR_PROXY_URI": "http://localhost:4001",
|
package/lib/proxy-client.js
CHANGED
|
@@ -202,6 +202,7 @@ class ProxyClient {
|
|
|
202
202
|
return resolve();
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// TODO: what if incoming message is received during graceful shutdown?
|
|
205
206
|
this.clientSession.close(() => {
|
|
206
207
|
log.debug('Successfully closed HTTP2 connection');
|
|
207
208
|
resolve();
|
|
@@ -237,62 +238,16 @@ class ProxyClient {
|
|
|
237
238
|
throw new Error('Connection lost and no reconnection in progress');
|
|
238
239
|
}
|
|
239
240
|
|
|
240
|
-
async
|
|
241
|
-
await this._ensureConnection();
|
|
242
|
-
|
|
243
|
-
const { body, headers } = message;
|
|
244
|
-
|
|
245
|
-
logger.info('Checking if incoming messages is lightweight...');
|
|
246
|
-
|
|
247
|
-
if (!headers) {
|
|
248
|
-
logger.info('Empty headers so not lightweight.');
|
|
249
|
-
return body;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const { [OBJECT_ID_HEADER]: objectId } = headers;
|
|
253
|
-
|
|
254
|
-
if (!objectId) {
|
|
255
|
-
logger.trace('No object id header so not lightweight.');
|
|
256
|
-
return body;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
logger.info('Object id header found, message is lightweight.', { objectId });
|
|
260
|
-
|
|
261
|
-
let object;
|
|
262
|
-
|
|
263
|
-
logger.info('Going to fetch message body.', { objectId });
|
|
264
|
-
|
|
241
|
+
async _proxyRequestWithRetries(operationName, requestFn) {
|
|
265
242
|
const maxRetries = this.settings.PROXY_OBJECT_REQUEST_RETRY_ATTEMPTS;
|
|
266
243
|
const retryDelay = this.settings.PROXY_OBJECT_REQUEST_RETRY_DELAY;
|
|
267
244
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
268
245
|
try {
|
|
269
|
-
|
|
270
|
-
const getObjectStream = this.clientSession.request({
|
|
271
|
-
[HTTP2_HEADER_PATH]: `/object/${objectId}`,
|
|
272
|
-
[HTTP2_HEADER_METHOD]: 'GET',
|
|
273
|
-
[HTTP2_HEADER_AUTHORIZATION]: this.authHeader
|
|
274
|
-
}).pipe(this._encryptor.createDecipher());
|
|
275
|
-
|
|
276
|
-
const chunks = [];
|
|
277
|
-
getObjectStream.on('data', chunk => {
|
|
278
|
-
chunks.push(chunk);
|
|
279
|
-
});
|
|
280
|
-
getObjectStream.on('error', (err) => {
|
|
281
|
-
logger.error(err, 'Error during fetching message body');
|
|
282
|
-
reject(err);
|
|
283
|
-
});
|
|
284
|
-
getObjectStream.on('end', () => {
|
|
285
|
-
logger.info('Message stream ended by server');
|
|
286
|
-
const buffer = Buffer.concat(chunks);
|
|
287
|
-
logger.info({ messageSize: buffer.length }, 'Received complete message from server');
|
|
288
|
-
resolve({ data: JSON.parse(buffer.toString()) });
|
|
289
|
-
});
|
|
290
|
-
});
|
|
291
|
-
|
|
246
|
+
const result = await requestFn();
|
|
292
247
|
if (attempt > 0) {
|
|
293
|
-
log.info({ attempt, maxRetries },
|
|
248
|
+
log.info({ attempt, maxRetries }, `${operationName} succeeded after retry`);
|
|
294
249
|
}
|
|
295
|
-
return
|
|
250
|
+
return result;
|
|
296
251
|
} catch (error) {
|
|
297
252
|
const isLastAttempt = attempt === maxRetries;
|
|
298
253
|
const isRetryable = error.isNetworkError ||
|
|
@@ -306,7 +261,7 @@ class ProxyClient {
|
|
|
306
261
|
maxRetries,
|
|
307
262
|
error: error.message,
|
|
308
263
|
isRetryable
|
|
309
|
-
},
|
|
264
|
+
}, `${operationName} failed, no more retries`);
|
|
310
265
|
throw error;
|
|
311
266
|
}
|
|
312
267
|
|
|
@@ -319,121 +274,120 @@ class ProxyClient {
|
|
|
319
274
|
maxRetries,
|
|
320
275
|
error: error.message,
|
|
321
276
|
nextRetryIn: delay
|
|
322
|
-
},
|
|
277
|
+
}, `${operationName} failed, retrying...`);
|
|
323
278
|
|
|
324
279
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
325
280
|
|
|
326
|
-
// Ensure connection is still valid before retry
|
|
327
281
|
if (!this.isConnected()) {
|
|
328
282
|
log.info('Reconnecting before retry...');
|
|
329
283
|
await this.connect();
|
|
330
284
|
}
|
|
331
285
|
}
|
|
332
286
|
}
|
|
333
|
-
logger.info('Successfully obtained message body.', { objectId });
|
|
334
|
-
logger.trace('Message body object received');
|
|
335
|
-
|
|
336
|
-
return object.data;
|
|
337
287
|
}
|
|
338
288
|
|
|
339
|
-
async
|
|
289
|
+
async fetchMessageBody(message, logger) {
|
|
340
290
|
await this._ensureConnection();
|
|
341
291
|
|
|
342
|
-
const
|
|
343
|
-
const retryDelay = this.settings.PROXY_OBJECT_REQUEST_RETRY_DELAY;
|
|
344
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
345
|
-
try {
|
|
346
|
-
const objectId = await new Promise((resolve, reject) => {
|
|
347
|
-
const postMessageStream = this.clientSession.request({
|
|
348
|
-
[HTTP2_HEADER_PATH]: '/object',
|
|
349
|
-
[HTTP2_HEADER_METHOD]: 'POST',
|
|
350
|
-
[HTTP2_HEADER_AUTHORIZATION]: this.authHeader
|
|
351
|
-
});
|
|
292
|
+
const { body, headers } = message;
|
|
352
293
|
|
|
353
|
-
|
|
354
|
-
let statusCode = null;
|
|
294
|
+
logger.info('Checking if incoming messages is lightweight...');
|
|
355
295
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
error.statusCode = statusCode;
|
|
361
|
-
return reject(error);
|
|
362
|
-
}
|
|
363
|
-
});
|
|
296
|
+
if (!headers) {
|
|
297
|
+
logger.info('Empty headers so not lightweight.');
|
|
298
|
+
return body;
|
|
299
|
+
}
|
|
364
300
|
|
|
365
|
-
|
|
366
|
-
responseData += chunk;
|
|
367
|
-
});
|
|
301
|
+
const { [OBJECT_ID_HEADER]: objectId } = headers;
|
|
368
302
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
});
|
|
303
|
+
if (!objectId) {
|
|
304
|
+
logger.trace('No object id header so not lightweight.');
|
|
305
|
+
return body;
|
|
306
|
+
}
|
|
374
307
|
|
|
375
|
-
|
|
376
|
-
if (!responseData) {
|
|
377
|
-
return
|
|
378
|
-
}
|
|
379
|
-
try {
|
|
380
|
-
const responseJson = JSON.parse(responseData);
|
|
381
|
-
resolve(responseJson.objectId);
|
|
382
|
-
} catch (e) {
|
|
383
|
-
log.error(e, 'Failed to parse upload message body response');
|
|
384
|
-
reject(e);
|
|
385
|
-
}
|
|
386
|
-
});
|
|
308
|
+
logger.info('Object id header found, message is lightweight.', { objectId });
|
|
387
309
|
|
|
388
|
-
|
|
389
|
-
cipher.pipe(postMessageStream);
|
|
390
|
-
cipher.write(bodyBuf);
|
|
391
|
-
cipher.end();
|
|
392
|
-
});
|
|
310
|
+
logger.info('Going to fetch message body.', { objectId });
|
|
393
311
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
312
|
+
await this._proxyRequestWithRetries('Fetch message body', () => new Promise((resolve, reject) => {
|
|
313
|
+
const getObjectStream = this.clientSession.request({
|
|
314
|
+
[HTTP2_HEADER_PATH]: `/object/${objectId}`,
|
|
315
|
+
[HTTP2_HEADER_METHOD]: 'GET',
|
|
316
|
+
[HTTP2_HEADER_AUTHORIZATION]: this.authHeader
|
|
317
|
+
}).pipe(this._encryptor.createDecipher());
|
|
399
318
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
319
|
+
const chunks = [];
|
|
320
|
+
getObjectStream.on('data', chunk => {
|
|
321
|
+
chunks.push(chunk);
|
|
322
|
+
});
|
|
323
|
+
getObjectStream.on('error', (err) => {
|
|
324
|
+
logger.error(err, 'Error during fetching message body');
|
|
325
|
+
reject(err);
|
|
326
|
+
});
|
|
327
|
+
getObjectStream.on('end', () => {
|
|
328
|
+
logger.info('Message stream ended by server');
|
|
329
|
+
const buffer = Buffer.concat(chunks);
|
|
330
|
+
logger.info({ messageSize: buffer.length }, 'Received complete message from server');
|
|
331
|
+
resolve({ data: JSON.parse(buffer.toString()) });
|
|
332
|
+
});
|
|
333
|
+
}));
|
|
406
334
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
335
|
+
logger.info('Successfully obtained message body.', { objectId });
|
|
336
|
+
logger.trace('Message body object received');
|
|
337
|
+
|
|
338
|
+
return objectId;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async uploadMessageBody(bodyBuf) {
|
|
342
|
+
await this._ensureConnection();
|
|
343
|
+
|
|
344
|
+
return this._proxyRequestWithRetries('Upload message body', () => new Promise((resolve, reject) => {
|
|
345
|
+
const postMessageStream = this.clientSession.request({
|
|
346
|
+
[HTTP2_HEADER_PATH]: '/object',
|
|
347
|
+
[HTTP2_HEADER_METHOD]: 'POST',
|
|
348
|
+
[HTTP2_HEADER_AUTHORIZATION]: this.authHeader
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
let responseData = '';
|
|
352
|
+
let statusCode = null;
|
|
353
|
+
|
|
354
|
+
postMessageStream.on('response', (headers, flags) => {
|
|
355
|
+
statusCode = headers[http2.constants.HTTP2_HEADER_STATUS];
|
|
356
|
+
if (statusCode !== 200) {
|
|
357
|
+
const error = new Error(`Failed to upload message body, status code: ${statusCode}`);
|
|
358
|
+
error.statusCode = statusCode;
|
|
359
|
+
return reject(error);
|
|
415
360
|
}
|
|
361
|
+
});
|
|
416
362
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
);
|
|
421
|
-
log.warn({
|
|
422
|
-
attempt,
|
|
423
|
-
maxRetries,
|
|
424
|
-
error: error.message,
|
|
425
|
-
nextRetryIn: delay
|
|
426
|
-
}, 'Upload message body failed, retrying...');
|
|
363
|
+
postMessageStream.on('data', chunk => {
|
|
364
|
+
responseData += chunk;
|
|
365
|
+
});
|
|
427
366
|
|
|
428
|
-
|
|
367
|
+
postMessageStream.on('error', (err) => {
|
|
368
|
+
log.error(err, 'Error during upload message body');
|
|
369
|
+
err.isNetworkError = true;
|
|
370
|
+
reject(err);
|
|
371
|
+
});
|
|
429
372
|
|
|
430
|
-
|
|
431
|
-
if (!
|
|
432
|
-
|
|
433
|
-
await this.connect();
|
|
373
|
+
postMessageStream.on('end', () => {
|
|
374
|
+
if (!responseData) {
|
|
375
|
+
return
|
|
434
376
|
}
|
|
435
|
-
|
|
436
|
-
|
|
377
|
+
try {
|
|
378
|
+
const responseJson = JSON.parse(responseData);
|
|
379
|
+
resolve(responseJson.objectId);
|
|
380
|
+
} catch (e) {
|
|
381
|
+
log.error(e, 'Failed to parse upload message body response');
|
|
382
|
+
reject(e);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const cipher = this._encryptor.createCipher();
|
|
387
|
+
cipher.pipe(postMessageStream);
|
|
388
|
+
cipher.write(bodyBuf);
|
|
389
|
+
cipher.end();
|
|
390
|
+
}));
|
|
437
391
|
}
|
|
438
392
|
|
|
439
393
|
async listenForMessages(messageHandler) {
|
|
@@ -542,33 +496,36 @@ class ProxyClient {
|
|
|
542
496
|
type,
|
|
543
497
|
...(customRoutingKey ? { customRoutingKey } : {})
|
|
544
498
|
}).toString();
|
|
545
|
-
// TODO: Add retries
|
|
546
|
-
const postMessageStream = this.clientSession.request({
|
|
547
|
-
...proxyHeaders,
|
|
548
|
-
[HTTP2_HEADER_PATH]: `/message?${queryParams}`,
|
|
549
|
-
[HTTP2_HEADER_METHOD]: 'POST',
|
|
550
|
-
[HTTP2_HEADER_AUTHORIZATION]: this.authHeader
|
|
551
|
-
});
|
|
552
|
-
postMessageStream.write(encryptedData);
|
|
553
|
-
postMessageStream.end();
|
|
554
499
|
|
|
555
|
-
|
|
500
|
+
await this._proxyRequestWithRetries('Send message', () => new Promise((resolve, reject) => {
|
|
501
|
+
const postMessageStream = this.clientSession.request({
|
|
502
|
+
...proxyHeaders,
|
|
503
|
+
[HTTP2_HEADER_PATH]: `/message?${queryParams}`,
|
|
504
|
+
[HTTP2_HEADER_METHOD]: 'POST',
|
|
505
|
+
[HTTP2_HEADER_AUTHORIZATION]: this.authHeader
|
|
506
|
+
});
|
|
507
|
+
postMessageStream.write(encryptedData);
|
|
508
|
+
postMessageStream.end();
|
|
509
|
+
|
|
556
510
|
postMessageStream.on('response', (headers) => {
|
|
557
511
|
log.debug({ status: headers[HTTP2_HEADER_STATUS] }, 'Send message response');
|
|
558
512
|
if (headers[HTTP2_HEADER_STATUS] !== 200) {
|
|
559
513
|
log.error({ headers }, 'Failed to send message');
|
|
560
|
-
|
|
514
|
+
const error = new Error(`Failed to send message, status code: ${headers[HTTP2_HEADER_STATUS]}`);
|
|
515
|
+
error.statusCode = headers[HTTP2_HEADER_STATUS];
|
|
516
|
+
return reject(error);
|
|
561
517
|
}
|
|
562
518
|
});
|
|
563
519
|
postMessageStream.on('error', (err) => {
|
|
564
520
|
log.error(err, 'Error during sending message');
|
|
521
|
+
err.isNetworkError = true;
|
|
565
522
|
reject(err);
|
|
566
523
|
});
|
|
567
524
|
postMessageStream.on('end', () => {
|
|
568
525
|
log.debug('Send message end event');
|
|
569
526
|
resolve();
|
|
570
527
|
});
|
|
571
|
-
});
|
|
528
|
+
}));
|
|
572
529
|
}
|
|
573
530
|
|
|
574
531
|
_decodeMessage(originalMessage, headers) {
|
|
@@ -620,30 +577,33 @@ class ProxyClient {
|
|
|
620
577
|
incomingMessageId,
|
|
621
578
|
status
|
|
622
579
|
}).toString();
|
|
623
|
-
// TODO: Add retries
|
|
624
|
-
const postMessageStream = this.clientSession.request({
|
|
625
|
-
[HTTP2_HEADER_PATH]: `/finish-processing?${queryParams}`,
|
|
626
|
-
[HTTP2_HEADER_METHOD]: 'POST',
|
|
627
|
-
[HTTP2_HEADER_AUTHORIZATION]: this.authHeader
|
|
628
|
-
});
|
|
629
|
-
postMessageStream.end();
|
|
630
580
|
|
|
631
|
-
|
|
581
|
+
await this._proxyRequestWithRetries('Finish processing', () => new Promise((resolve, reject) => {
|
|
582
|
+
const postMessageStream = this.clientSession.request({
|
|
583
|
+
[HTTP2_HEADER_PATH]: `/finish-processing?${queryParams}`,
|
|
584
|
+
[HTTP2_HEADER_METHOD]: 'POST',
|
|
585
|
+
[HTTP2_HEADER_AUTHORIZATION]: this.authHeader
|
|
586
|
+
});
|
|
587
|
+
postMessageStream.end();
|
|
588
|
+
|
|
632
589
|
postMessageStream.on('response', (headers) => {
|
|
633
590
|
log.debug({ status: headers[HTTP2_HEADER_STATUS] }, 'Finish processing response event');
|
|
634
591
|
if (headers[HTTP2_HEADER_STATUS] !== 200) {
|
|
635
592
|
log.error({ headers }, 'Failed to finish processing message');
|
|
636
|
-
|
|
593
|
+
const error = new Error(`Failed to finish processing message, status code: ${headers[HTTP2_HEADER_STATUS]}`);
|
|
594
|
+
error.statusCode = headers[HTTP2_HEADER_STATUS];
|
|
595
|
+
return reject(error);
|
|
637
596
|
}
|
|
638
597
|
});
|
|
639
|
-
|
|
640
598
|
postMessageStream.on('end', () => {
|
|
641
599
|
log.debug('Finish processing end event');
|
|
642
600
|
resolve();
|
|
643
601
|
});
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
602
|
+
postMessageStream.on('error', (err) => {
|
|
603
|
+
err.isNetworkError = true;
|
|
604
|
+
reject(err);
|
|
605
|
+
});
|
|
606
|
+
}));
|
|
647
607
|
}
|
|
648
608
|
|
|
649
609
|
encryptMessageContent(body, protocolVersion = 1) {
|
package/lib/sailor.js
CHANGED
|
@@ -80,6 +80,7 @@ class Sailor {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
async disconnect() {
|
|
83
|
+
// TODO: delete if not needed (currently used only in old tests)
|
|
83
84
|
log.debug('Disconnecting, %s messages in processing', this.messagesCount);
|
|
84
85
|
return this.proxyClient.disconnect();
|
|
85
86
|
}
|
|
@@ -194,7 +195,6 @@ class Sailor {
|
|
|
194
195
|
return new Promise(resolve => this.shutdownCallback = resolve);
|
|
195
196
|
}
|
|
196
197
|
|
|
197
|
-
// TODO: remove duplicate disconnect call (also in run.js)
|
|
198
198
|
await this.proxyClient.disconnect();
|
|
199
199
|
if (this.messagesCount === 0) {
|
|
200
200
|
// there is no unfinished processMessage invocation, let's just resolve scheduleShutdown now
|
package/mise.toml
ADDED
package/package.json
CHANGED
package/run.js
CHANGED
|
@@ -57,23 +57,6 @@ async function putOutToSea(settings, ipc) {
|
|
|
57
57
|
ipc.send('init:ended');
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
async function disconnectAndExit() {
|
|
61
|
-
if (!disconnectRequired) {
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
disconnectRequired = false;
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
logger.info('Disconnecting...');
|
|
68
|
-
await sailor.disconnect();
|
|
69
|
-
logger.info('Successfully disconnected');
|
|
70
|
-
process.exit();
|
|
71
|
-
} catch (err) {
|
|
72
|
-
logger.error(err, 'Unable to disconnect');
|
|
73
|
-
process.exit(-1);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
60
|
async function gracefulShutdown() {
|
|
78
61
|
if (!disconnectRequired) {
|
|
79
62
|
return;
|
|
@@ -90,8 +73,15 @@ async function gracefulShutdown() {
|
|
|
90
73
|
await sailorInit;
|
|
91
74
|
logger.trace('Waited an init before graceful shutdown');
|
|
92
75
|
|
|
93
|
-
|
|
94
|
-
|
|
76
|
+
try {
|
|
77
|
+
logger.info('Disconnecting...');
|
|
78
|
+
await sailor.scheduleShutdown();
|
|
79
|
+
logger.info('Successfully disconnected');
|
|
80
|
+
process.exit();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
logger.error(err, 'Unable to disconnect');
|
|
83
|
+
process.exit(-1);
|
|
84
|
+
}
|
|
95
85
|
}
|
|
96
86
|
|
|
97
87
|
async function run(settings, ipc) {
|