@swimmingliu/autovpn 1.6.8 → 1.6.9

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.
@@ -308,6 +308,24 @@ async function forEachWithConcurrency(items, concurrency, mapper) {
308
308
  throw firstError;
309
309
  }
310
310
  }
311
+ function createLimiter(concurrency) {
312
+ const limit = Math.max(1, Math.trunc(Number(concurrency) || 1));
313
+ let active = 0;
314
+ const queue = [];
315
+ return async (task) => {
316
+ if (active >= limit) {
317
+ await new Promise((resolve) => queue.push(resolve));
318
+ }
319
+ active += 1;
320
+ try {
321
+ return await task();
322
+ }
323
+ finally {
324
+ active -= 1;
325
+ queue.shift()?.();
326
+ }
327
+ };
328
+ }
311
329
  async function speedtestResumeStateFromEventLog(eventLog) {
312
330
  const probes = new Map();
313
331
  const fullResults = new Map();
@@ -414,6 +432,65 @@ export async function runNodePipeline(options, context = {}) {
414
432
  let availabilityResults = [];
415
433
  let availableLinks = [];
416
434
  const streamedDeduped = new Set();
435
+ const streamingAvailabilityTasks = [];
436
+ const limitAvailability = createLimiter(speedConfig.concurrency);
437
+ let availabilityCompleted = 0;
438
+ let availabilityStageStart;
439
+ let speedArtifactFlush = Promise.resolve();
440
+ let availabilityArtifactFlush = Promise.resolve();
441
+ const flushStreamingSpeedArtifacts = async (results, links) => {
442
+ const resultSnapshot = [...results];
443
+ const linkSnapshot = [...links];
444
+ speedArtifactFlush = speedArtifactFlush.catch(() => undefined).then(async () => {
445
+ await writeLines(artifactDir, 'vpn_node_speedtest.txt', linkSnapshot);
446
+ await writeJson(artifactDir, 'vpn_node_speedtest_report.json', resultSnapshot);
447
+ });
448
+ await speedArtifactFlush;
449
+ };
450
+ const flushStreamingAvailabilityArtifacts = async () => {
451
+ const resultSnapshot = [...availabilityResults];
452
+ const linkSnapshot = resultSnapshot.filter((result) => result.all_passed).map((result) => result.link);
453
+ availabilityArtifactFlush = availabilityArtifactFlush.catch(() => undefined).then(async () => {
454
+ await writeLines(artifactDir, 'vpn_node_availability.txt', linkSnapshot);
455
+ await writeJson(artifactDir, 'vpn_node_availability_report.json', resultSnapshot);
456
+ });
457
+ await availabilityArtifactFlush;
458
+ };
459
+ const ensureAvailabilityStageStarted = () => {
460
+ availabilityStageStart ??= setStage('availability', 'running', false);
461
+ return availabilityStageStart;
462
+ };
463
+ const runStreamingAvailability = (speedResult) => {
464
+ const task = limitAvailability(async () => {
465
+ await ensureAvailabilityStageStarted();
466
+ const results = context.stages?.availability
467
+ ? await context.stages.availability([speedResult], profile.speed_test ?? {}, runtimePath, profile.availability_targets)
468
+ : await checkLinkAvailabilityBatchWithBackend({
469
+ results: [speedResult],
470
+ config: profile.speed_test ?? {},
471
+ runtime_path: runtimePath,
472
+ targets: profile.availability_targets
473
+ }, { cwd: projectRoot, env: runtimeStageEnv });
474
+ for (const result of results) {
475
+ availabilityResults.push(result);
476
+ availabilityCompleted += 1;
477
+ await flushStreamingAvailabilityArtifacts();
478
+ const statuses = Object.entries(result.provider_results)
479
+ .map(([name, provider]) => `${name}=${provider.passed ? 'ok' : provider.reason}`)
480
+ .join(' ');
481
+ emit('log', { message: `[availability] ${availabilityCompleted}/${passedSpeedLinks.length} ${statuses}` });
482
+ emit('availability_link_result', {
483
+ completed: availabilityCompleted,
484
+ total: passedSpeedLinks.length,
485
+ link: result.link,
486
+ all_passed: result.all_passed,
487
+ provider_results: result.provider_results
488
+ });
489
+ }
490
+ });
491
+ streamingAvailabilityTasks.push(task);
492
+ task.catch(() => undefined);
493
+ };
417
494
  const onExtractedLinks = async (links) => {
418
495
  for (const link of links) {
419
496
  rawLinks.push(link);
@@ -428,8 +505,6 @@ export async function runNodePipeline(options, context = {}) {
428
505
  await setStage('extract', 'running');
429
506
  if (useStreamingStages) {
430
507
  await setStage('dedupe', 'running', false);
431
- await setStage('speedtest', 'running', false);
432
- await setStage('availability', 'running', false);
433
508
  }
434
509
  const extractResults = await Promise.all(sourcesToRun.map(async ([sourceName, source]) => {
435
510
  const streamedLinks = new Set();
@@ -501,19 +576,23 @@ export async function runNodePipeline(options, context = {}) {
501
576
  ? await context.stages.speedtestProbe(dedupedLinks, profile.speed_test ?? {}, runtimePath)
502
577
  : await probeSpeedtestLinksInNode({ links: dedupedLinks, config: profile.speed_test, runtime_path: runtimePath }, {
503
578
  cwd: projectRoot,
504
- env: runtimeStageEnv
505
- });
506
- for (let index = 0; index < probes.length; index += 1) {
507
- const result = probes[index];
508
- emit('log', { message: `[speedtest:probe] ${index + 1}/${dedupedLinks.length} reachable=${result.reachable} latency=${result.latency_ms}ms` });
509
- emit('speedtest_probe_result', {
510
- completed: index + 1,
511
- total: dedupedLinks.length,
512
- link: result.link,
513
- reachable: result.reachable,
514
- latency_ms: result.latency_ms,
515
- error: result.error ?? ''
579
+ env: runtimeStageEnv,
580
+ progressCallback: (message) => emit('log', { message }),
581
+ eventCallback: (type, payload) => emit(type, payload)
516
582
  });
583
+ if (context.stages?.speedtestProbe) {
584
+ for (let index = 0; index < probes.length; index += 1) {
585
+ const result = probes[index];
586
+ emit('log', { message: `[speedtest:probe] ${index + 1}/${dedupedLinks.length} reachable=${result.reachable} latency=${result.latency_ms}ms` });
587
+ emit('speedtest_probe_result', {
588
+ completed: index + 1,
589
+ total: dedupedLinks.length,
590
+ link: result.link,
591
+ reachable: result.reachable,
592
+ latency_ms: result.latency_ms,
593
+ error: result.error ?? ''
594
+ });
595
+ }
517
596
  }
518
597
  const candidateLinks = selectSpeedtestCandidates(probes, speedConfig.max_download_candidates);
519
598
  const reachableCount = probes.filter((probe) => probe.reachable).length;
@@ -537,33 +616,56 @@ export async function runNodePipeline(options, context = {}) {
537
616
  }
538
617
  }
539
618
  let speedCompleted = 0;
540
- await forEachWithConcurrency(candidateLinks, speedConfig.concurrency, async (link) => {
541
- const result = context.stages?.speedtestLink
542
- ? await context.stages.speedtestLink(link, profile.speed_test ?? {}, runtimePath)
543
- : await testSpeedtestLinkInNode({ link, config: profile.speed_test, runtime_path: runtimePath }, {
544
- cwd: projectRoot,
545
- env: runtimeStageEnv
619
+ try {
620
+ await forEachWithConcurrency(candidateLinks, speedConfig.concurrency, async (link) => {
621
+ const result = context.stages?.speedtestLink
622
+ ? await context.stages.speedtestLink(link, profile.speed_test ?? {}, runtimePath)
623
+ : await testSpeedtestLinkInNode({ link, config: profile.speed_test, runtime_path: runtimePath }, {
624
+ cwd: projectRoot,
625
+ env: runtimeStageEnv
626
+ });
627
+ if (result.reachable && result.latency_ms <= 0) {
628
+ result.latency_ms = probeByLink.get(result.link)?.latency_ms ?? 0;
629
+ }
630
+ resultByLink.set(result.link, result);
631
+ speedCompleted += 1;
632
+ const passed = result.reachable && result.average_download_mb_s >= speedConfig.min_download_mb_s;
633
+ emit('speedtest_result', {
634
+ completed: speedCompleted,
635
+ total: candidateLinks.length,
636
+ link: result.link,
637
+ reachable: result.reachable,
638
+ average_download_mb_s: result.average_download_mb_s,
639
+ latency_ms: result.latency_ms,
640
+ passed_threshold: passed,
641
+ error: result.error ?? ''
546
642
  });
547
- if (result.reachable && result.latency_ms <= 0) {
548
- result.latency_ms = probeByLink.get(result.link)?.latency_ms ?? 0;
549
- }
550
- resultByLink.set(result.link, result);
551
- speedCompleted += 1;
552
- const passed = result.reachable && result.average_download_mb_s >= speedConfig.min_download_mb_s;
553
- emit('speedtest_result', {
554
- completed: speedCompleted,
555
- total: candidateLinks.length,
556
- link: result.link,
557
- reachable: result.reachable,
558
- average_download_mb_s: result.average_download_mb_s,
559
- latency_ms: result.latency_ms,
560
- passed_threshold: passed,
561
- error: result.error ?? ''
562
- });
563
- emit('log', {
564
- message: `[speedtest] ${speedCompleted}/${candidateLinks.length} reachable=${result.reachable} speed=${result.average_download_mb_s}MB/s`
643
+ emit('log', {
644
+ message: `[speedtest] ${speedCompleted}/${candidateLinks.length} reachable=${result.reachable} speed=${result.average_download_mb_s}MB/s`
645
+ });
646
+ const currentSpeedResults = [
647
+ ...probes.filter((probe) => !probe.reachable).map((probe) => resultByLink.get(probe.link)),
648
+ ...candidateLinks.map((candidate) => resultByLink.get(candidate)).filter((entry) => Boolean(entry))
649
+ ];
650
+ const currentPassedLinks = candidateLinks.filter((candidate) => {
651
+ const entry = resultByLink.get(candidate);
652
+ return Boolean(entry?.reachable && entry.average_download_mb_s >= speedConfig.min_download_mb_s);
653
+ });
654
+ summary.counts.speedtest_links = currentPassedLinks.length;
655
+ await flushStreamingSpeedArtifacts(currentSpeedResults, currentPassedLinks);
656
+ if (passed) {
657
+ passedSpeedLinks.push(result.link);
658
+ runStreamingAvailability(result);
659
+ }
565
660
  });
566
- });
661
+ }
662
+ catch (error) {
663
+ await Promise.allSettled(streamingAvailabilityTasks);
664
+ if (summary.stage_status.availability === 'running') {
665
+ await setStage('availability', 'failed', false);
666
+ }
667
+ throw error;
668
+ }
567
669
  speedResults = [
568
670
  ...probes.filter((probe) => !probe.reachable).map((probe) => resultByLink.get(probe.link)),
569
671
  ...candidateLinks.map((link) => resultByLink.get(link)).filter((result) => Boolean(result))
@@ -592,21 +694,40 @@ export async function runNodePipeline(options, context = {}) {
592
694
  if (dedupedLinks.length > 0 && passedSpeedLinks.length === 0) {
593
695
  throw new Error(speedtestFailureMessage(speedResults, Number(profile.speed_test?.min_download_mb_s ?? 0)));
594
696
  }
595
- await setStage('speedtest', 'success', !useStreamingStages);
596
- if (summary.stage_status.availability !== 'running') {
697
+ await setStage('speedtest', 'success');
698
+ if (useStreamingStages) {
699
+ try {
700
+ await Promise.all(streamingAvailabilityTasks);
701
+ }
702
+ catch (error) {
703
+ await setStage('availability', 'failed', false);
704
+ throw error;
705
+ }
706
+ const availabilityByLink = new Map(availabilityResults.map((result) => [result.link, result]));
707
+ availabilityResults = passedSpeedLinks.map((link) => availabilityByLink.get(link)).filter((result) => Boolean(result));
708
+ }
709
+ else if (summary.stage_status.availability !== 'running') {
597
710
  await setStage('availability', 'running');
598
711
  }
599
712
  const speedResultByLink = new Map(speedResults.map((result) => [result.link, result]));
600
713
  const candidateSpeedResults = passedSpeedLinks.map((link) => speedResultByLink.get(link)).filter((result) => Boolean(result));
601
- availabilityResults = context.stages?.availability
602
- ? await context.stages.availability(candidateSpeedResults, profile.speed_test ?? {}, runtimePath, profile.availability_targets)
603
- : await checkLinkAvailabilityBatchWithBackend({
604
- results: candidateSpeedResults,
605
- config: profile.speed_test ?? {},
606
- runtime_path: runtimePath,
607
- targets: profile.availability_targets
608
- }, { cwd: projectRoot, env: runtimeStageEnv });
609
- availableLinks = availabilityResults.filter((result) => result.all_passed).map((result) => result.link);
714
+ availabilityResults = useStreamingStages
715
+ ? availabilityResults
716
+ : context.stages?.availability
717
+ ? await context.stages.availability(candidateSpeedResults, profile.speed_test ?? {}, runtimePath, profile.availability_targets)
718
+ : await checkLinkAvailabilityBatchWithBackend({
719
+ results: candidateSpeedResults,
720
+ config: profile.speed_test ?? {},
721
+ runtime_path: runtimePath,
722
+ targets: profile.availability_targets
723
+ }, {
724
+ cwd: projectRoot,
725
+ env: runtimeStageEnv,
726
+ progressCallback: (message) => emit('log', { message }),
727
+ eventCallback: (type, payload) => emit(type, payload)
728
+ });
729
+ const availabilityByLinkForOrder = new Map(availabilityResults.map((result) => [result.link, result]));
730
+ availableLinks = passedSpeedLinks.filter((link) => availabilityByLinkForOrder.get(link)?.all_passed);
610
731
  summary.counts.availability_links = availableLinks.length;
611
732
  await writeLines(artifactDir, 'vpn_node_availability.txt', availableLinks);
612
733
  await writeJson(artifactDir, 'vpn_node_availability_report.json', availabilityResults);
@@ -647,8 +768,8 @@ export async function runNodePipeline(options, context = {}) {
647
768
  await setStage('deploy', 'skipped');
648
769
  }
649
770
  else {
650
- assertDeployMinimumFinalLinks(profile, postprocessed.links);
651
771
  await setStage('deploy', 'running');
772
+ assertDeployMinimumFinalLinks(profile, postprocessed.links);
652
773
  const deployment = context.stages?.deploy
653
774
  ? await context.stages.deploy({ projectRoot, bundleDir, profile })
654
775
  : await deployPagesWithBackend({ projectRoot, bundleDir, deploy: profile.deploy ?? {} }, { cwd: projectRoot, env });
@@ -869,8 +990,8 @@ export async function retryNodePipelineStage(options, context = {}) {
869
990
  if (!fs.existsSync(bundleDir)) {
870
991
  throw new Error('No Pages bundle available to retry deploy');
871
992
  }
872
- assertDeployMinimumFinalLinks(profile, finalLinks);
873
993
  await setStage('deploy', 'running');
994
+ assertDeployMinimumFinalLinks(profile, finalLinks);
874
995
  const deployment = context.stages?.deploy
875
996
  ? await context.stages.deploy({ projectRoot, bundleDir, profile })
876
997
  : await deployPagesWithBackend({ projectRoot, bundleDir, deploy: profile.deploy ?? {} }, { cwd: projectRoot, env });
@@ -1186,8 +1307,8 @@ export async function resumeNodePipeline(options, context = {}) {
1186
1307
  await setStage('verify', 'skipped');
1187
1308
  }
1188
1309
  else {
1189
- assertDeployMinimumFinalLinks(profile, postprocessed.links);
1190
1310
  await setStage('deploy', 'running');
1311
+ assertDeployMinimumFinalLinks(profile, postprocessed.links);
1191
1312
  const deployment = context.stages?.deploy
1192
1313
  ? await context.stages.deploy({ projectRoot, bundleDir, profile })
1193
1314
  : await deployPagesWithBackend({ projectRoot, bundleDir, deploy: profile.deploy ?? {} }, { cwd: projectRoot, env });
@@ -66,7 +66,11 @@ async function mapWithConcurrency(items, concurrency, worker, onComplete) {
66
66
  onComplete?.(result, index, completed);
67
67
  }
68
68
  }
69
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runWorker()));
69
+ const workers = await Promise.allSettled(Array.from({ length: Math.min(limit, items.length) }, () => runWorker()));
70
+ const failed = workers.find((result) => result.status === 'rejected');
71
+ if (failed) {
72
+ throw failed.reason;
73
+ }
70
74
  return results;
71
75
  }
72
76
  function defaultNow() {
@@ -367,7 +371,7 @@ export async function downloadUrlViaHttpProxy(url, proxyUrl, maxBytes, timeoutSe
367
371
  ].join('\r\n'));
368
372
  return await readHttpBodyBytes(secureSocket, Math.max(1, maxBytes), timeoutMs);
369
373
  }
370
- async function probeLinksDirect(links, config, options) {
374
+ async function probeLinksDirect(links, config, options, onComplete) {
371
375
  const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
372
376
  if (!fetchImpl) {
373
377
  throw new Error('Node speedtest backend requires fetch support');
@@ -387,9 +391,9 @@ async function probeLinksDirect(links, config, options) {
387
391
  catch (error) {
388
392
  return { link, reachable: false, latency_ms: 0, error: error instanceof Error ? error.message : String(error) };
389
393
  }
390
- });
394
+ }, (result, _index, completed) => onComplete?.(result, completed));
391
395
  }
392
- async function probeLinksMihomo(links, config, runtimePath, options) {
396
+ async function probeLinksMihomo(links, config, runtimePath, options, onComplete) {
393
397
  const openRuntime = options.openMihomoRuntime ?? defaultOpenMihomoRuntime;
394
398
  const probeDelay = options.probeMihomoProxyDelay ?? defaultProbeMihomoProxyDelay;
395
399
  return mapWithConcurrency(links, config.concurrency, async (link) => {
@@ -413,7 +417,7 @@ async function probeLinksMihomo(links, config, runtimePath, options) {
413
417
  catch (error) {
414
418
  return { link, reachable: false, latency_ms: 0, error: error instanceof Error ? error.message : String(error) };
415
419
  }
416
- });
420
+ }, (result, _index, completed) => onComplete?.(result, completed));
417
421
  }
418
422
  async function testLinkDirect(link, config, options) {
419
423
  const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
@@ -587,10 +591,25 @@ export async function probeSpeedtestLinksInNode(input, options = {}) {
587
591
  const runtimePath = input.runtime_path ?? '';
588
592
  const requestedRuntime = String((options.env ?? process.env).AUTOVPN_SPEEDTEST_RUNTIME ?? '').trim().toLowerCase();
589
593
  const useMihomoRuntime = requestedRuntime !== 'direct';
590
- const probeLinks = options.probeLinks ?? ((links) => (useMihomoRuntime
591
- ? probeLinksMihomo(links, config, runtimePath, options)
592
- : probeLinksDirect(links, config, options)));
593
- return probeLinks(input.links, config, { runtime_path: runtimePath });
594
+ const emitProbe = (result, completed) => {
595
+ options.progressCallback?.(`[speedtest:probe] ${completed}/${input.links.length} reachable=${result.reachable} latency=${result.latency_ms}ms`);
596
+ emitEvent(options.eventCallback, 'speedtest_probe_result', {
597
+ completed,
598
+ total: input.links.length,
599
+ link: result.link,
600
+ reachable: result.reachable,
601
+ latency_ms: result.latency_ms,
602
+ error: result.error ?? ''
603
+ });
604
+ };
605
+ if (options.probeLinks) {
606
+ const results = await options.probeLinks(input.links, config, { runtime_path: runtimePath });
607
+ results.forEach((result, index) => emitProbe(result, index + 1));
608
+ return results;
609
+ }
610
+ return useMihomoRuntime
611
+ ? probeLinksMihomo(input.links, config, runtimePath, options, emitProbe)
612
+ : probeLinksDirect(input.links, config, options, emitProbe);
594
613
  }
595
614
  export async function testSpeedtestLinkInNode(input, options = {}) {
596
615
  const config = normalizeSpeedTestConfig(input.config);
@@ -5,7 +5,7 @@ const ZH_MESSAGES = {
5
5
  locale: 'zh-CN',
6
6
  appTitle: 'AutoVPN',
7
7
  sidebarTitle: 'AutoVPN',
8
- sidebarVersion: 'v.1.6.8',
8
+ sidebarVersion: 'v.1.6.9',
9
9
  brandSubtitle: '概览、运行、结果、订阅、日志、设置统一管理',
10
10
  languageLabel: '',
11
11
  saveButton: '保存配置',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmingliu/autovpn",
3
- "version": "1.6.8",
3
+ "version": "1.6.9",
4
4
  "description": "npm wrapper for the AutoVPN headless CLI",
5
5
  "type": "module",
6
6
  "repository": {