@swimmingliu/autovpn 2.0.3 → 2.1.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.
- package/dist/cli/accounts-commands.js +123 -7
- package/dist/cli/output.js +1 -1
- package/dist/config/profile.js +40 -9
- package/dist/pipeline/availability.js +201 -150
- package/dist/pipeline/concurrency.js +68 -0
- package/dist/pipeline/credential-source.js +490 -99
- package/dist/pipeline/egress.js +378 -0
- package/dist/pipeline/extract.js +108 -69
- package/dist/pipeline/http-transport.js +232 -0
- package/dist/pipeline/orchestrator.js +34 -8
- package/dist/pipeline/proxy-runtime.js +35 -2
- package/dist/pipeline/speedtest.js +187 -144
- package/dist/pool/account-store.js +46 -4
- package/dist/server/http.js +11 -0
- package/dist/server/runtime.js +27 -1
- package/dist/server/web-adapter.js +12 -0
- package/dist/web/renderer/app.js +115 -10
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/styles.css +37 -2
- package/dist/web/renderer/tables.js +2 -2
- package/dist/web/renderer/views.js +156 -20
- package/dist/web/renderer/workspace.css +5 -0
- package/package.json +1 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
-
import
|
|
2
|
+
import https from 'node:https';
|
|
3
3
|
import tls from 'node:tls';
|
|
4
4
|
import { openMihomoRuntime as defaultOpenMihomoRuntime } from './proxy-runtime.js';
|
|
5
|
+
import { mapWithCompletion } from './concurrency.js';
|
|
6
|
+
import { HTTP_HEAD_LIMIT_BYTES, connectProxySocket, httpStatusLine, parseProxyEndpoint, safeParseHttpStatus, splitHttpResponseHead } from './http-transport.js';
|
|
5
7
|
import { isTransientNetworkError, retryTransientNetwork } from './network-retry.js';
|
|
6
8
|
const PROVIDER_TARGETS = [
|
|
7
9
|
{ name: 'gemini', url: 'https://gemini.google.com', allowed_hosts: ['gemini.google.com'], negative_phrases: [] },
|
|
@@ -265,24 +267,6 @@ function emitEvent(callback, eventType, payload) {
|
|
|
265
267
|
callback(eventType, payload);
|
|
266
268
|
}
|
|
267
269
|
}
|
|
268
|
-
async function mapWithConcurrency(items, concurrency, worker, onComplete) {
|
|
269
|
-
const limit = Math.max(1, Math.trunc(Number(concurrency) || 1));
|
|
270
|
-
const results = new Array(items.length);
|
|
271
|
-
let nextIndex = 0;
|
|
272
|
-
let completed = 0;
|
|
273
|
-
async function runWorker() {
|
|
274
|
-
while (nextIndex < items.length) {
|
|
275
|
-
const index = nextIndex;
|
|
276
|
-
nextIndex += 1;
|
|
277
|
-
const result = await worker(items[index], index);
|
|
278
|
-
results[index] = result;
|
|
279
|
-
completed += 1;
|
|
280
|
-
onComplete?.(result, index, completed);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runWorker()));
|
|
284
|
-
return results;
|
|
285
|
-
}
|
|
286
270
|
function numberOrDefault(value, fallback) {
|
|
287
271
|
const parsed = Number(value);
|
|
288
272
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
@@ -334,41 +318,6 @@ async function checkLinkAvailabilityDirect(speedResult, config, options) {
|
|
|
334
318
|
provider_results: providerResults
|
|
335
319
|
};
|
|
336
320
|
}
|
|
337
|
-
function socketConnect(host, port, timeoutMs) {
|
|
338
|
-
return new Promise((resolve, reject) => {
|
|
339
|
-
const socket = net.createConnection({ host, port });
|
|
340
|
-
const timer = setTimeout(() => {
|
|
341
|
-
socket.destroy();
|
|
342
|
-
reject(new Error(`proxy connection timed out after ${timeoutMs}ms`));
|
|
343
|
-
}, timeoutMs);
|
|
344
|
-
socket.once('connect', () => {
|
|
345
|
-
clearTimeout(timer);
|
|
346
|
-
resolve(socket);
|
|
347
|
-
});
|
|
348
|
-
socket.once('error', (error) => {
|
|
349
|
-
clearTimeout(timer);
|
|
350
|
-
reject(error);
|
|
351
|
-
});
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
function splitHttpHeaders(buffer) {
|
|
355
|
-
const marker = buffer.indexOf('\r\n\r\n');
|
|
356
|
-
if (marker < 0) {
|
|
357
|
-
return undefined;
|
|
358
|
-
}
|
|
359
|
-
return {
|
|
360
|
-
head: buffer.subarray(0, marker),
|
|
361
|
-
body: buffer.subarray(marker + 4)
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
function parseHttpStatus(head) {
|
|
365
|
-
const firstLine = head.toString('latin1').split('\r\n')[0] ?? '';
|
|
366
|
-
const match = /^HTTP\/\d(?:\.\d)?\s+(\d+)/i.exec(firstLine);
|
|
367
|
-
if (!match) {
|
|
368
|
-
throw new Error(`invalid HTTP response: ${firstLine}`);
|
|
369
|
-
}
|
|
370
|
-
return Number(match[1]);
|
|
371
|
-
}
|
|
372
321
|
function parseHttpHeaders(head) {
|
|
373
322
|
const headers = {};
|
|
374
323
|
for (const line of head.toString('latin1').split('\r\n').slice(1)) {
|
|
@@ -404,13 +353,24 @@ function redirectTarget(current, response) {
|
|
|
404
353
|
}
|
|
405
354
|
function fetchHttpUrlViaHttpProxy(target, proxy, timeoutMs) {
|
|
406
355
|
return new Promise((resolve, reject) => {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
356
|
+
let settled = false;
|
|
357
|
+
const settle = (action) => {
|
|
358
|
+
if (settled) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
settled = true;
|
|
362
|
+
action();
|
|
363
|
+
};
|
|
364
|
+
const endpoint = parseProxyEndpoint(proxy);
|
|
365
|
+
const requestModule = endpoint.protocol === 'https:' ? https : http;
|
|
366
|
+
const request = requestModule.request({
|
|
367
|
+
hostname: endpoint.hostname,
|
|
368
|
+
port: endpoint.port,
|
|
410
369
|
method: 'GET',
|
|
411
370
|
path: target.toString(),
|
|
412
371
|
headers: {
|
|
413
372
|
Host: target.host,
|
|
373
|
+
...(endpoint.authorization ? { 'Proxy-Authorization': endpoint.authorization } : {}),
|
|
414
374
|
Connection: 'close'
|
|
415
375
|
},
|
|
416
376
|
agent: false,
|
|
@@ -419,21 +379,47 @@ function fetchHttpUrlViaHttpProxy(target, proxy, timeoutMs) {
|
|
|
419
379
|
const chunks = [];
|
|
420
380
|
response.on('data', (chunk) => chunks.push(chunk));
|
|
421
381
|
response.once('end', () => {
|
|
422
|
-
resolve({
|
|
382
|
+
settle(() => resolve({
|
|
423
383
|
final_url: target.toString(),
|
|
424
384
|
status_code: Number(response.statusCode ?? 0),
|
|
425
385
|
body: Buffer.concat(chunks).toString('utf8'),
|
|
426
386
|
headers: response.headers
|
|
427
|
-
});
|
|
387
|
+
}));
|
|
388
|
+
// `agent: false` + Connection: close, but destroy explicitly so a
|
|
389
|
+
// keep-alive-capable agent can never retain the socket.
|
|
390
|
+
request.destroy();
|
|
391
|
+
});
|
|
392
|
+
// A response that errors mid-body used to abort the process; surface it as
|
|
393
|
+
// a per-target failure and tear the request down instead.
|
|
394
|
+
response.once('error', (error) => {
|
|
395
|
+
settle(() => reject(error));
|
|
396
|
+
request.destroy();
|
|
397
|
+
});
|
|
398
|
+
response.once('aborted', () => {
|
|
399
|
+
settle(() => reject(new Error('proxy response aborted before the body was received')));
|
|
400
|
+
request.destroy();
|
|
428
401
|
});
|
|
429
402
|
});
|
|
430
403
|
request.once('timeout', () => {
|
|
431
404
|
request.destroy(new Error(`proxy fetch timed out after ${timeoutMs}ms`));
|
|
432
405
|
});
|
|
433
|
-
request.once('error',
|
|
406
|
+
request.once('error', (error) => {
|
|
407
|
+
settle(() => reject(error));
|
|
408
|
+
});
|
|
434
409
|
request.end();
|
|
435
410
|
});
|
|
436
411
|
}
|
|
412
|
+
function destroyQuietly(socket) {
|
|
413
|
+
if (!socket || socket.destroyed) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
socket.destroy();
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
// already torn down
|
|
421
|
+
}
|
|
422
|
+
}
|
|
437
423
|
function readHttpResponse(socket, timeoutMs) {
|
|
438
424
|
return new Promise((resolve, reject) => {
|
|
439
425
|
let buffered = Buffer.alloc(0);
|
|
@@ -441,46 +427,68 @@ function readHttpResponse(socket, timeoutMs) {
|
|
|
441
427
|
let status = 0;
|
|
442
428
|
let headers = {};
|
|
443
429
|
const chunks = [];
|
|
444
|
-
|
|
445
|
-
cleanup();
|
|
446
|
-
socket.destroy();
|
|
447
|
-
reject(new Error(`proxy fetch timed out after ${timeoutMs}ms`));
|
|
448
|
-
}, timeoutMs);
|
|
430
|
+
let settled = false;
|
|
449
431
|
const cleanup = () => {
|
|
450
432
|
clearTimeout(timer);
|
|
451
433
|
socket.off('data', onData);
|
|
452
434
|
socket.off('end', onEnd);
|
|
453
435
|
socket.off('error', onError);
|
|
454
436
|
};
|
|
455
|
-
const
|
|
437
|
+
const settle = (action) => {
|
|
438
|
+
if (settled) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
settled = true;
|
|
456
442
|
cleanup();
|
|
457
|
-
|
|
443
|
+
destroyQuietly(socket);
|
|
444
|
+
action();
|
|
458
445
|
};
|
|
459
|
-
const
|
|
460
|
-
|
|
446
|
+
const fail = (error) => settle(() => reject(error));
|
|
447
|
+
const timer = setTimeout(() => fail(new Error(`proxy fetch timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
448
|
+
function onError(error) {
|
|
449
|
+
fail(error);
|
|
450
|
+
}
|
|
451
|
+
function onEnd() {
|
|
461
452
|
if (!headersParsed) {
|
|
462
|
-
|
|
453
|
+
fail(new Error('proxy response ended before HTTP headers were received'));
|
|
463
454
|
return;
|
|
464
455
|
}
|
|
465
|
-
resolve({ status, headers, body: Buffer.concat(chunks).toString('utf8') });
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
if (
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
456
|
+
settle(() => resolve({ status, headers, body: Buffer.concat(chunks).toString('utf8') }));
|
|
457
|
+
}
|
|
458
|
+
function onData(chunk) {
|
|
459
|
+
if (settled) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
if (!headersParsed) {
|
|
464
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
465
|
+
const split = splitHttpResponseHead(buffered);
|
|
466
|
+
if (!split) {
|
|
467
|
+
if (buffered.byteLength > HTTP_HEAD_LIMIT_BYTES) {
|
|
468
|
+
fail(new Error(`invalid HTTP response: ${httpStatusLine(buffered)}`));
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const parsed = safeParseHttpStatus(split.head);
|
|
473
|
+
if (parsed === null) {
|
|
474
|
+
fail(new Error(`invalid HTTP response: ${httpStatusLine(split.head)}`));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
status = parsed;
|
|
478
|
+
headers = parseHttpHeaders(split.head);
|
|
479
|
+
headersParsed = true;
|
|
480
|
+
buffered = Buffer.alloc(0);
|
|
481
|
+
if (split.body.byteLength > 0) {
|
|
482
|
+
chunks.push(split.body);
|
|
483
|
+
}
|
|
472
484
|
return;
|
|
473
485
|
}
|
|
474
|
-
|
|
475
|
-
headers = parseHttpHeaders(split.head);
|
|
476
|
-
headersParsed = true;
|
|
477
|
-
if (split.body.byteLength > 0) {
|
|
478
|
-
chunks.push(split.body);
|
|
479
|
-
}
|
|
480
|
-
return;
|
|
486
|
+
chunks.push(chunk);
|
|
481
487
|
}
|
|
482
|
-
|
|
483
|
-
|
|
488
|
+
catch (error) {
|
|
489
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
484
492
|
socket.on('data', onData);
|
|
485
493
|
socket.once('end', onEnd);
|
|
486
494
|
socket.once('error', onError);
|
|
@@ -489,59 +497,88 @@ function readHttpResponse(socket, timeoutMs) {
|
|
|
489
497
|
function readConnectResponse(socket, timeoutMs) {
|
|
490
498
|
return new Promise((resolve, reject) => {
|
|
491
499
|
let buffered = Buffer.alloc(0);
|
|
492
|
-
|
|
493
|
-
cleanup();
|
|
494
|
-
socket.destroy();
|
|
495
|
-
reject(new Error(`proxy CONNECT timed out after ${timeoutMs}ms`));
|
|
496
|
-
}, timeoutMs);
|
|
500
|
+
let settled = false;
|
|
497
501
|
const cleanup = () => {
|
|
498
502
|
clearTimeout(timer);
|
|
499
503
|
socket.off('data', onData);
|
|
504
|
+
socket.off('end', onEnd);
|
|
500
505
|
socket.off('error', onError);
|
|
501
506
|
};
|
|
502
|
-
const
|
|
507
|
+
const fail = (error) => {
|
|
508
|
+
if (settled) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
settled = true;
|
|
503
512
|
cleanup();
|
|
513
|
+
destroyQuietly(socket);
|
|
504
514
|
reject(error);
|
|
505
515
|
};
|
|
506
|
-
const
|
|
516
|
+
const timer = setTimeout(() => fail(new Error(`proxy CONNECT timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
517
|
+
function onError(error) {
|
|
518
|
+
fail(error);
|
|
519
|
+
}
|
|
520
|
+
function onEnd() {
|
|
521
|
+
fail(new Error('proxy CONNECT response ended before HTTP headers were received'));
|
|
522
|
+
}
|
|
523
|
+
function onData(chunk) {
|
|
524
|
+
if (settled) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
507
527
|
buffered = Buffer.concat([buffered, chunk]);
|
|
508
|
-
const split =
|
|
528
|
+
const split = splitHttpResponseHead(buffered);
|
|
509
529
|
if (!split) {
|
|
530
|
+
if (buffered.byteLength > HTTP_HEAD_LIMIT_BYTES) {
|
|
531
|
+
fail(new Error(`invalid HTTP response: ${httpStatusLine(buffered)}`));
|
|
532
|
+
}
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const status = safeParseHttpStatus(split.head);
|
|
536
|
+
if (status === null) {
|
|
537
|
+
fail(new Error(`invalid HTTP response: ${httpStatusLine(split.head)}`));
|
|
510
538
|
return;
|
|
511
539
|
}
|
|
512
|
-
const status = parseHttpStatus(split.head);
|
|
513
|
-
cleanup();
|
|
514
540
|
if (status < 200 || status >= 300) {
|
|
515
|
-
|
|
516
|
-
reject(new Error(`proxy CONNECT failed with status ${status}`));
|
|
541
|
+
fail(new Error(`proxy CONNECT failed with status ${status}`));
|
|
517
542
|
return;
|
|
518
543
|
}
|
|
544
|
+
settled = true;
|
|
545
|
+
cleanup();
|
|
519
546
|
resolve();
|
|
520
|
-
}
|
|
547
|
+
}
|
|
521
548
|
socket.on('data', onData);
|
|
549
|
+
socket.once('end', onEnd);
|
|
522
550
|
socket.once('error', onError);
|
|
523
551
|
});
|
|
524
552
|
}
|
|
525
553
|
function waitForSecureConnect(socket, timeoutMs) {
|
|
526
554
|
return new Promise((resolve, reject) => {
|
|
527
|
-
|
|
528
|
-
cleanup();
|
|
529
|
-
socket.destroy();
|
|
530
|
-
reject(new Error(`proxy TLS handshake timed out after ${timeoutMs}ms`));
|
|
531
|
-
}, timeoutMs);
|
|
555
|
+
let settled = false;
|
|
532
556
|
const cleanup = () => {
|
|
533
557
|
clearTimeout(timer);
|
|
534
558
|
socket.off('secureConnect', onSecureConnect);
|
|
535
559
|
socket.off('error', onError);
|
|
536
560
|
};
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
561
|
+
const fail = (error) => {
|
|
562
|
+
if (settled) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
settled = true;
|
|
542
566
|
cleanup();
|
|
567
|
+
destroyQuietly(socket);
|
|
543
568
|
reject(error);
|
|
544
569
|
};
|
|
570
|
+
const timer = setTimeout(() => fail(new Error(`proxy TLS handshake timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
571
|
+
function onSecureConnect() {
|
|
572
|
+
if (settled) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
settled = true;
|
|
576
|
+
cleanup();
|
|
577
|
+
resolve();
|
|
578
|
+
}
|
|
579
|
+
function onError(error) {
|
|
580
|
+
fail(error);
|
|
581
|
+
}
|
|
545
582
|
socket.once('secureConnect', onSecureConnect);
|
|
546
583
|
socket.once('error', onError);
|
|
547
584
|
});
|
|
@@ -565,49 +602,63 @@ async function fetchUrlViaHttpProxyTarget(target, proxy, timeoutMs, redirectsRem
|
|
|
565
602
|
if (target.protocol !== 'https:') {
|
|
566
603
|
throw new Error(`unsupported availability URL protocol: ${target.protocol}`);
|
|
567
604
|
}
|
|
568
|
-
const
|
|
569
|
-
const socket = await socketConnect(proxy.hostname, proxyPort, timeoutMs);
|
|
605
|
+
const endpoint = parseProxyEndpoint(proxy);
|
|
570
606
|
const targetPort = Number(target.port || 443);
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
''
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
607
|
+
const proxyAuthorization = endpoint.authorization ? [`Proxy-Authorization: ${endpoint.authorization}`] : [];
|
|
608
|
+
const socket = await connectProxySocket(endpoint, timeoutMs);
|
|
609
|
+
let secureSocket;
|
|
610
|
+
try {
|
|
611
|
+
socket.write([
|
|
612
|
+
`CONNECT ${target.hostname}:${targetPort} HTTP/1.1`,
|
|
613
|
+
`Host: ${target.hostname}:${targetPort}`,
|
|
614
|
+
...proxyAuthorization,
|
|
615
|
+
'Connection: keep-alive',
|
|
616
|
+
'',
|
|
617
|
+
''
|
|
618
|
+
].join('\r\n'));
|
|
619
|
+
await readConnectResponse(socket, timeoutMs);
|
|
620
|
+
secureSocket = tls.connect({
|
|
621
|
+
socket,
|
|
622
|
+
servername: target.hostname,
|
|
623
|
+
rejectUnauthorized: true
|
|
624
|
+
});
|
|
625
|
+
// Destroying a TLSSocket after the request-scoped listeners are removed
|
|
626
|
+
// must not surface as an uncaught 'error'.
|
|
627
|
+
secureSocket.on('error', () => { });
|
|
628
|
+
await waitForSecureConnect(secureSocket, timeoutMs);
|
|
629
|
+
secureSocket.write([
|
|
630
|
+
`GET ${target.pathname || '/'}${target.search} HTTP/1.1`,
|
|
631
|
+
`Host: ${target.host}`,
|
|
632
|
+
'Connection: close',
|
|
633
|
+
'',
|
|
634
|
+
''
|
|
635
|
+
].join('\r\n'));
|
|
636
|
+
const response = await readHttpResponse(secureSocket, timeoutMs);
|
|
637
|
+
const proxiedResponse = {
|
|
638
|
+
final_url: target.toString(),
|
|
639
|
+
status_code: response.status,
|
|
640
|
+
body: response.body,
|
|
641
|
+
headers: response.headers
|
|
642
|
+
};
|
|
643
|
+
const next = redirectTarget(target, proxiedResponse);
|
|
644
|
+
if (next) {
|
|
645
|
+
if (redirectsRemaining <= 0) {
|
|
646
|
+
throw new Error(`too many redirects while fetching ${target.toString()}`);
|
|
647
|
+
}
|
|
648
|
+
return await fetchUrlViaHttpProxyTarget(next, proxy, timeoutMs, redirectsRemaining - 1);
|
|
603
649
|
}
|
|
604
|
-
return
|
|
650
|
+
return {
|
|
651
|
+
final_url: target.toString(),
|
|
652
|
+
status_code: response.status,
|
|
653
|
+
body: response.body
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
finally {
|
|
657
|
+
// Every terminal branch (success, redirect, parse failure, timeout) must
|
|
658
|
+
// release both handles; destroy() is idempotent so the readers can too.
|
|
659
|
+
destroyQuietly(secureSocket);
|
|
660
|
+
destroyQuietly(socket);
|
|
605
661
|
}
|
|
606
|
-
return {
|
|
607
|
-
final_url: target.toString(),
|
|
608
|
-
status_code: response.status,
|
|
609
|
-
body: response.body
|
|
610
|
-
};
|
|
611
662
|
}
|
|
612
663
|
export async function fetchUrlViaHttpProxy(url, proxyUrl, timeoutSeconds) {
|
|
613
664
|
const timeoutMs = Math.max(1, timeoutSeconds) * 1000;
|
|
@@ -670,7 +721,7 @@ async function checkBatchInNode(input, options) {
|
|
|
670
721
|
runtimePath: input.runtime_path ?? ''
|
|
671
722
|
})
|
|
672
723
|
: checkLinkAvailabilityDirect(speedResult, config, { targets, fetch: options.fetch })));
|
|
673
|
-
return
|
|
724
|
+
return mapWithCompletion(input.results, numberOrDefault(input.config.concurrency, 1), async (speedResult) => {
|
|
674
725
|
let availability;
|
|
675
726
|
try {
|
|
676
727
|
availability = availabilityResultToDict(await checkLinkAvailability(speedResult, input.config, {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded fan-out over a fixed work list.
|
|
3
|
+
*
|
|
4
|
+
* Three stages used to carry their own copy of this loop (speed test,
|
|
5
|
+
* availability probe, credential rounds). They drifted: one swallowed worker
|
|
6
|
+
* errors, one lost input order, none capped a non-finite limit. One definition
|
|
7
|
+
* keeps the contract the same everywhere:
|
|
8
|
+
*
|
|
9
|
+
* - results keep input order, so a caller can zip them back onto its items;
|
|
10
|
+
* - at most `limit` workers are in flight, and `limit` is clamped to >= 1;
|
|
11
|
+
* - a worker rejection propagates, but the workers already in flight are
|
|
12
|
+
* awaited first so no promise is left running after the call returns.
|
|
13
|
+
*/
|
|
14
|
+
export async function mapWithConcurrency(items, limit, worker) {
|
|
15
|
+
if (items.length === 0)
|
|
16
|
+
return [];
|
|
17
|
+
const width = Math.max(1, Math.min(items.length, Math.trunc(Number(limit)) || 1));
|
|
18
|
+
const results = new Array(items.length);
|
|
19
|
+
let cursor = 0;
|
|
20
|
+
let failed = false;
|
|
21
|
+
let failure;
|
|
22
|
+
const runners = Array.from({ length: width }, async () => {
|
|
23
|
+
while (!failed) {
|
|
24
|
+
const index = cursor;
|
|
25
|
+
cursor += 1;
|
|
26
|
+
if (index >= items.length)
|
|
27
|
+
return;
|
|
28
|
+
try {
|
|
29
|
+
results[index] = await worker(items[index], index);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (!failed) {
|
|
33
|
+
failed = true;
|
|
34
|
+
failure = error;
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
await Promise.all(runners);
|
|
41
|
+
if (failed)
|
|
42
|
+
throw failure;
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
/** Clamp a user-supplied concurrency knob into a sane range. */
|
|
46
|
+
export function resolveConcurrency(value, fallback, max = 16) {
|
|
47
|
+
const parsed = Math.trunc(Number(value));
|
|
48
|
+
if (!Number.isFinite(parsed) || parsed < 1)
|
|
49
|
+
return Math.max(1, Math.min(max, Math.trunc(fallback)));
|
|
50
|
+
return Math.max(1, Math.min(max, parsed));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `mapWithConcurrency` plus a progress counter, for a stage that reports
|
|
54
|
+
* "N of M done" while it works. The callback runs after the result is
|
|
55
|
+
* recorded, so `completed` counts settled items, not started ones.
|
|
56
|
+
*/
|
|
57
|
+
export async function mapWithCompletion(items, concurrency, worker, onComplete) {
|
|
58
|
+
if (!onComplete) {
|
|
59
|
+
return mapWithConcurrency(items, concurrency, worker);
|
|
60
|
+
}
|
|
61
|
+
let completed = 0;
|
|
62
|
+
return mapWithConcurrency(items, concurrency, async (item, index) => {
|
|
63
|
+
const result = await worker(item, index);
|
|
64
|
+
completed += 1;
|
|
65
|
+
onComplete(result, index, completed);
|
|
66
|
+
return result;
|
|
67
|
+
});
|
|
68
|
+
}
|