@swimmingliu/autovpn 1.6.1 → 1.6.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.
@@ -6,7 +6,7 @@ import { mergeProjectEnv } from '../runtime/env.js';
6
6
  import { resolveArtifactsRoot, resolveProfilePath } from '../runtime/paths.js';
7
7
  import { redactText } from '../runtime/redaction.js';
8
8
  import { fetchSourceLinksWithBackend } from './extract.js';
9
- import { dedupeVmessLinksWithBackend } from './dedupe.js';
9
+ import { canonicalVmessKey, dedupeVmessLinksWithBackend, parseVmessLink } from './dedupe.js';
10
10
  import { normalizeSpeedTestConfig, probeSpeedtestLinksInNode, selectSpeedtestCandidates, speedtestLinksWithBackend, testSpeedtestLinkInNode } from './speedtest.js';
11
11
  import { checkLinkAvailabilityBatchWithBackend } from './availability.js';
12
12
  import { decorateLinkWithCountry, postprocessLinksWithBackend } from './postprocess.js';
@@ -69,6 +69,27 @@ async function readLines(filePath) {
69
69
  }
70
70
  return (await readFile(filePath, 'utf8')).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
71
71
  }
72
+ function speedtestFailureMessage(results, minDownloadMbS) {
73
+ const reachable = results.filter((result) => result.reachable);
74
+ if (reachable.length === 0) {
75
+ return 'No links passed speed test';
76
+ }
77
+ const bestSpeed = Math.max(...reachable.map((result) => Number(result.average_download_mb_s) || 0));
78
+ return `No links met minimum speed threshold ${minDownloadMbS}MB/s; best speed was ${bestSpeed}MB/s`;
79
+ }
80
+ function deployMinimumFinalLinks(profile) {
81
+ const raw = Number(profile.deploy?.min_final_links ?? 10);
82
+ if (!Number.isFinite(raw) || raw < 0) {
83
+ return 10;
84
+ }
85
+ return Math.trunc(raw);
86
+ }
87
+ function assertDeployMinimumFinalLinks(profile, finalLinks) {
88
+ const minimum = deployMinimumFinalLinks(profile);
89
+ if (minimum > 0 && finalLinks.length < minimum) {
90
+ throw new Error(`final node count ${finalLinks.length} is below deploy minimum ${minimum}`);
91
+ }
92
+ }
72
93
  async function copyIfExists(source, destination) {
73
94
  if (!fs.existsSync(source)) {
74
95
  return;
@@ -140,6 +161,14 @@ function orderPreservingUnique(values) {
140
161
  }
141
162
  return result;
142
163
  }
164
+ function streamingDedupeKey(link) {
165
+ try {
166
+ return canonicalVmessKey(parseVmessLink(link));
167
+ }
168
+ catch {
169
+ return link;
170
+ }
171
+ }
143
172
  function defaultCountryFor(_link, _speedResult, _availabilityResult) {
144
173
  return 'US';
145
174
  }
@@ -264,6 +293,24 @@ async function forEachWithConcurrency(items, concurrency, mapper) {
264
293
  }
265
294
  }));
266
295
  }
296
+ function createLimiter(concurrency) {
297
+ const limit = Math.max(1, Math.trunc(concurrency));
298
+ let active = 0;
299
+ const queue = [];
300
+ return async (task) => {
301
+ if (active >= limit) {
302
+ await new Promise((resolve) => queue.push(resolve));
303
+ }
304
+ active += 1;
305
+ try {
306
+ return await task();
307
+ }
308
+ finally {
309
+ active -= 1;
310
+ queue.shift()?.();
311
+ }
312
+ };
313
+ }
267
314
  async function speedtestResumeStateFromEventLog(eventLog) {
268
315
  const probes = new Map();
269
316
  const fullResults = new Map();
@@ -341,9 +388,11 @@ export async function runNodePipeline(options, context = {}) {
341
388
  };
342
389
  const writeReport = () => writeJson(artifactDir, 'pipeline_report.json', summary);
343
390
  let activeStage;
344
- const setStage = async (stage, status) => {
391
+ const setStage = async (stage, status, trackActive = true) => {
345
392
  summary.stage_status[stage] = status;
346
- activeStage = status === 'running' ? stage : activeStage === stage ? undefined : activeStage;
393
+ if (trackActive) {
394
+ activeStage = status === 'running' ? stage : activeStage === stage ? undefined : activeStage;
395
+ }
347
396
  emit('stage', { stage, status });
348
397
  await writeReport();
349
398
  };
@@ -357,16 +406,126 @@ export async function runNodePipeline(options, context = {}) {
357
406
  await setStage('doctor', 'running');
358
407
  await setStage('doctor', 'success');
359
408
  const profile = await readProfile(projectRoot, env);
360
- await setStage('extract', 'running');
361
409
  const sourcesToRun = enabledSources(profile);
410
+ const runtimePath = path.join(artifactDir, 'runtime');
411
+ const speedConfig = normalizeSpeedTestConfig(profile.speed_test);
412
+ const useStreamingStages = !context.stages?.speedtest || Boolean(context.stages.speedtestLink);
413
+ let rawLinks = [];
414
+ let dedupedLinks = [];
415
+ let speedResults = [];
416
+ let passedSpeedLinks = [];
417
+ let availabilityResults = [];
418
+ let availableLinks = [];
419
+ const streamedDeduped = new Set();
420
+ const speedTasks = [];
421
+ const availabilityTasks = [];
422
+ const limitSpeedtest = createLimiter(Number(speedConfig.concurrency ?? 1));
423
+ const limitAvailability = createLimiter(Number(speedConfig.concurrency ?? 1));
424
+ let speedCompleted = 0;
425
+ let availabilityCompleted = 0;
426
+ const runStreamingAvailability = (speedResult) => {
427
+ const task = limitAvailability(async () => {
428
+ const results = context.stages?.availability
429
+ ? await context.stages.availability([speedResult], profile.speed_test ?? {}, runtimePath, profile.availability_targets)
430
+ : await checkLinkAvailabilityBatchWithBackend({
431
+ results: [speedResult],
432
+ config: profile.speed_test ?? {},
433
+ runtime_path: runtimePath,
434
+ targets: profile.availability_targets
435
+ }, { cwd: projectRoot, env: runtimeStageEnv });
436
+ for (const result of results) {
437
+ availabilityResults.push(result);
438
+ availabilityCompleted += 1;
439
+ if (context.stages?.availability) {
440
+ emit('availability_link_result', {
441
+ completed: availabilityCompleted,
442
+ total: passedSpeedLinks.length,
443
+ link: result.link,
444
+ all_passed: result.all_passed,
445
+ provider_results: result.provider_results
446
+ });
447
+ }
448
+ }
449
+ });
450
+ availabilityTasks.push(task);
451
+ task.catch(() => undefined);
452
+ };
453
+ const runStreamingSpeedtest = (link) => {
454
+ const task = limitSpeedtest(async () => {
455
+ const result = context.stages?.speedtestLink
456
+ ? await context.stages.speedtestLink(link, profile.speed_test ?? {}, runtimePath)
457
+ : await testSpeedtestLinkInNode({ link, config: profile.speed_test, runtime_path: runtimePath }, {
458
+ cwd: projectRoot,
459
+ env: runtimeStageEnv
460
+ });
461
+ speedResults.push(result);
462
+ speedCompleted += 1;
463
+ const passed = result.reachable && result.average_download_mb_s >= speedConfig.min_download_mb_s;
464
+ emit('speedtest_result', {
465
+ completed: speedCompleted,
466
+ total: dedupedLinks.length,
467
+ link: result.link,
468
+ reachable: result.reachable,
469
+ average_download_mb_s: result.average_download_mb_s,
470
+ latency_ms: result.latency_ms,
471
+ passed_threshold: passed,
472
+ error: result.error ?? ''
473
+ });
474
+ emit('log', {
475
+ message: `[speedtest] ${speedCompleted}/${dedupedLinks.length} reachable=${result.reachable} speed=${result.average_download_mb_s}MB/s`
476
+ });
477
+ if (passed) {
478
+ passedSpeedLinks.push(result.link);
479
+ runStreamingAvailability(result);
480
+ }
481
+ });
482
+ speedTasks.push(task);
483
+ task.catch(() => undefined);
484
+ };
485
+ const onExtractedLinks = async (links) => {
486
+ for (const link of links) {
487
+ rawLinks.push(link);
488
+ const key = streamingDedupeKey(link);
489
+ if (streamedDeduped.has(key)) {
490
+ continue;
491
+ }
492
+ streamedDeduped.add(key);
493
+ dedupedLinks.push(link);
494
+ runStreamingSpeedtest(link);
495
+ }
496
+ };
497
+ await setStage('extract', 'running');
498
+ if (useStreamingStages) {
499
+ await setStage('dedupe', 'running', false);
500
+ await setStage('speedtest', 'running', false);
501
+ await setStage('availability', 'running', false);
502
+ }
362
503
  const extractResults = await Promise.all(sourcesToRun.map(async ([sourceName, source]) => {
504
+ const streamedLinks = new Set();
505
+ const stream = useStreamingStages
506
+ ? {
507
+ onLinks: async (links) => {
508
+ for (const link of links) {
509
+ streamedLinks.add(link);
510
+ }
511
+ await onExtractedLinks(links);
512
+ }
513
+ }
514
+ : undefined;
363
515
  const result = context.stages?.extract
364
- ? await context.stages.extract({ source_name: sourceName, source })
516
+ ? await context.stages.extract({ source_name: sourceName, source }, stream)
365
517
  : await fetchSourceLinksWithBackend({ source_name: sourceName, source }, {
366
518
  cwd: projectRoot,
367
519
  env: runtimeStageEnv,
368
- eventCallback: (type, payload) => emit(type, payload)
520
+ eventCallback: (type, payload) => emit(type, payload),
521
+ linksCallback: stream?.onLinks
369
522
  });
523
+ if (useStreamingStages && stream) {
524
+ const missingLinks = result.links.filter((link) => !streamedLinks.has(link));
525
+ if (missingLinks.length > 0) {
526
+ await stream.onLinks(missingLinks);
527
+ }
528
+ }
370
529
  summary.source_counts[result.source_name] = {
371
530
  raw_links: result.links.length,
372
531
  successful_iterations: result.successful_iterations,
@@ -374,48 +533,82 @@ export async function runNodePipeline(options, context = {}) {
374
533
  };
375
534
  return result;
376
535
  }));
377
- const rawLinks = extractResults.flatMap((result) => result.links);
536
+ if (!useStreamingStages) {
537
+ rawLinks = extractResults.flatMap((result) => result.links);
538
+ }
378
539
  if (sourcesToRun.length > 0 && rawLinks.length === 0 && extractResults.some((result) => result.requested_iterations > 0 || result.failed_iterations > 0)) {
379
540
  throw new Error('No links extracted from configured sources');
380
541
  }
381
542
  summary.counts.raw_links = rawLinks.length;
382
543
  await writeLines(artifactDir, 'vpn_node_raw.txt', rawLinks);
383
544
  await setStage('extract', 'success');
384
- await setStage('dedupe', 'running');
385
- const dedupedLinks = context.stages?.extract
386
- ? orderPreservingUnique(rawLinks)
387
- : await dedupeVmessLinksWithBackend(rawLinks, { cwd: projectRoot, env });
545
+ if (summary.stage_status.dedupe !== 'running') {
546
+ await setStage('dedupe', 'running');
547
+ }
548
+ dedupedLinks = useStreamingStages
549
+ ? dedupedLinks
550
+ : context.stages?.extract
551
+ ? orderPreservingUnique(rawLinks)
552
+ : await dedupeVmessLinksWithBackend(rawLinks, { cwd: projectRoot, env });
388
553
  summary.counts.deduped_links = dedupedLinks.length;
389
554
  await writeLines(artifactDir, 'vpn_node_deduped.txt', dedupedLinks);
390
- await setStage('dedupe', 'success');
391
- await setStage('speedtest', 'running');
392
- const runtimePath = path.join(artifactDir, 'runtime');
393
- const speedResults = context.stages?.speedtest
394
- ? await context.stages.speedtest(dedupedLinks, profile.speed_test ?? {}, runtimePath)
395
- : await speedtestLinksWithBackend({ links: dedupedLinks, config: profile.speed_test, runtime_path: runtimePath }, { cwd: projectRoot, env: runtimeStageEnv });
396
- const passedSpeedLinks = speedResults
397
- .filter((result) => result.reachable && result.average_download_mb_s >= Number(profile.speed_test?.min_download_mb_s ?? 0))
398
- .map((result) => result.link);
555
+ await setStage('dedupe', 'success', !useStreamingStages);
556
+ if (summary.stage_status.speedtest !== 'running') {
557
+ await setStage('speedtest', 'running');
558
+ }
559
+ if (useStreamingStages) {
560
+ const requestedRuntime = String(runtimeStageEnv.AUTOVPN_SPEEDTEST_RUNTIME ?? '').trim().toLowerCase();
561
+ emit('speedtest_runtime', {
562
+ runtime_core: requestedRuntime === 'direct' ? 'direct' : 'mihomo',
563
+ probe_url: speedConfig.probe_url,
564
+ urls: [...speedConfig.urls]
565
+ });
566
+ emit('log', {
567
+ message: `[speedtest] runtime_core=${requestedRuntime === 'direct' ? 'direct' : 'mihomo'} probe_url=${speedConfig.probe_url}`
568
+ });
569
+ await Promise.all(speedTasks);
570
+ await Promise.all(availabilityTasks);
571
+ }
572
+ else {
573
+ speedResults = context.stages?.speedtest
574
+ ? await context.stages.speedtest(dedupedLinks, profile.speed_test ?? {}, runtimePath)
575
+ : await speedtestLinksWithBackend({ links: dedupedLinks, config: profile.speed_test, runtime_path: runtimePath }, {
576
+ cwd: projectRoot,
577
+ env: runtimeStageEnv,
578
+ progressCallback: (message) => emit('log', { message }),
579
+ eventCallback: (type, payload) => emit(type, payload)
580
+ });
581
+ passedSpeedLinks = speedResults
582
+ .filter((result) => result.reachable && result.average_download_mb_s >= Number(profile.speed_test?.min_download_mb_s ?? 0))
583
+ .map((result) => result.link);
584
+ }
399
585
  summary.counts.speedtest_links = passedSpeedLinks.length;
400
586
  await writeLines(artifactDir, 'vpn_node_speedtest.txt', passedSpeedLinks);
401
587
  await writeJson(artifactDir, 'vpn_node_speedtest_report.json', speedResults);
402
- await setStage('speedtest', 'success');
403
- await setStage('availability', 'running');
588
+ if (dedupedLinks.length > 0 && passedSpeedLinks.length === 0) {
589
+ throw new Error(speedtestFailureMessage(speedResults, Number(profile.speed_test?.min_download_mb_s ?? 0)));
590
+ }
591
+ await setStage('speedtest', 'success', !useStreamingStages);
592
+ if (summary.stage_status.availability !== 'running') {
593
+ await setStage('availability', 'running');
594
+ }
404
595
  const speedResultByLink = new Map(speedResults.map((result) => [result.link, result]));
405
596
  const candidateSpeedResults = passedSpeedLinks.map((link) => speedResultByLink.get(link)).filter((result) => Boolean(result));
406
- const availabilityResults = context.stages?.availability
407
- ? await context.stages.availability(candidateSpeedResults, profile.speed_test ?? {}, runtimePath, profile.availability_targets)
408
- : await checkLinkAvailabilityBatchWithBackend({
409
- results: candidateSpeedResults,
410
- config: profile.speed_test ?? {},
411
- runtime_path: runtimePath,
412
- targets: profile.availability_targets
413
- }, { cwd: projectRoot, env: runtimeStageEnv });
414
- const availableLinks = availabilityResults.filter((result) => result.all_passed).map((result) => result.link);
597
+ availabilityResults = useStreamingStages
598
+ ? availabilityResults
599
+ : context.stages?.availability
600
+ ? await context.stages.availability(candidateSpeedResults, profile.speed_test ?? {}, runtimePath, profile.availability_targets)
601
+ : await checkLinkAvailabilityBatchWithBackend({
602
+ results: candidateSpeedResults,
603
+ config: profile.speed_test ?? {},
604
+ runtime_path: runtimePath,
605
+ targets: profile.availability_targets
606
+ }, { cwd: projectRoot, env: runtimeStageEnv });
607
+ availableLinks = availabilityResults.filter((result) => result.all_passed).map((result) => result.link);
415
608
  summary.counts.availability_links = availableLinks.length;
416
609
  await writeLines(artifactDir, 'vpn_node_availability.txt', availableLinks);
417
610
  await writeJson(artifactDir, 'vpn_node_availability_report.json', availabilityResults);
418
- await setStage('availability', 'success');
611
+ await setStage('availability', 'success', !useStreamingStages);
419
612
  await setStage('postprocess', 'running');
420
613
  const countryLookup = context.stages?.countryLookup ?? defaultCountryFor;
421
614
  const availabilityByLink = new Map(availabilityResults.map((result) => [result.link, result]));
@@ -448,6 +641,7 @@ export async function runNodePipeline(options, context = {}) {
448
641
  await setStage('deploy', 'skipped');
449
642
  }
450
643
  else {
644
+ assertDeployMinimumFinalLinks(profile, postprocessed.links);
451
645
  await setStage('deploy', 'running');
452
646
  const deployment = context.stages?.deploy
453
647
  ? await context.stages.deploy({ projectRoot, bundleDir, profile })
@@ -547,7 +741,12 @@ export async function retryNodePipelineStage(options, context = {}) {
547
741
  await setStage('speedtest', 'running');
548
742
  speedResults = context.stages?.speedtest
549
743
  ? await context.stages.speedtest(dedupedLinks, profile.speed_test ?? {}, runtimePath)
550
- : await speedtestLinksWithBackend({ links: dedupedLinks, config: profile.speed_test, runtime_path: runtimePath }, { cwd: projectRoot, env: runtimeStageEnv });
744
+ : await speedtestLinksWithBackend({ links: dedupedLinks, config: profile.speed_test, runtime_path: runtimePath }, {
745
+ cwd: projectRoot,
746
+ env: runtimeStageEnv,
747
+ progressCallback: (message) => emit('log', { message }),
748
+ eventCallback: (type, payload) => emit(type, payload)
749
+ });
551
750
  const passedSpeedLinks = speedResults
552
751
  .filter((result) => result.reachable && result.average_download_mb_s >= Number(profile.speed_test?.min_download_mb_s ?? 0))
553
752
  .map((result) => result.link);
@@ -557,9 +756,9 @@ export async function retryNodePipelineStage(options, context = {}) {
557
756
  if (passedSpeedLinks.length === 0) {
558
757
  await setStage('speedtest', 'failed');
559
758
  summary.run_status = 'failed';
560
- summary.error = 'Error: No links passed speed test';
759
+ summary.error = `Error: ${speedtestFailureMessage(speedResults, Number(profile.speed_test?.min_download_mb_s ?? 0))}`;
561
760
  await writeReport();
562
- throw new Error('No links passed speed test');
761
+ throw new Error(speedtestFailureMessage(speedResults, Number(profile.speed_test?.min_download_mb_s ?? 0)));
563
762
  }
564
763
  speedResults = speedResults.filter((result) => passedSpeedLinks.includes(result.link));
565
764
  await setStage('speedtest', 'success');
@@ -662,6 +861,7 @@ export async function retryNodePipelineStage(options, context = {}) {
662
861
  if (!fs.existsSync(bundleDir)) {
663
862
  throw new Error('No Pages bundle available to retry deploy');
664
863
  }
864
+ assertDeployMinimumFinalLinks(profile, finalLinks);
665
865
  await setStage('deploy', 'running');
666
866
  const deployment = context.stages?.deploy
667
867
  ? await context.stages.deploy({ projectRoot, bundleDir, profile })
@@ -760,8 +960,8 @@ async function resumeNodeSpeedtest(options, context = {}) {
760
960
  const speedConfig = normalizeSpeedTestConfig(profile.speed_test);
761
961
  const requestedRuntime = String(env.AUTOVPN_SPEEDTEST_RUNTIME ?? '').trim().toLowerCase();
762
962
  const usingInjectedSpeedtestStages = Boolean(context.stages?.speedtestProbe && context.stages?.speedtestLink);
763
- if (requestedRuntime !== 'mihomo' && !usingInjectedSpeedtestStages) {
764
- throw new Error('Node resume speedtest requires AUTOVPN_SPEEDTEST_RUNTIME=mihomo');
963
+ if (requestedRuntime === 'direct' && !usingInjectedSpeedtestStages) {
964
+ throw new Error('Node resume speedtest cannot use AUTOVPN_SPEEDTEST_RUNTIME=direct');
765
965
  }
766
966
  const remainingProbeLinks = dedupedLinks.filter((link) => !probes.has(link));
767
967
  if (remainingProbeLinks.length > 0) {
@@ -826,9 +1026,9 @@ async function resumeNodeSpeedtest(options, context = {}) {
826
1026
  if (fastResults.length === 0) {
827
1027
  await setStage('speedtest', 'failed');
828
1028
  summary.run_status = 'failed';
829
- summary.error = 'Error: No links passed speed test';
1029
+ summary.error = `Error: ${speedtestFailureMessage(orderedFullResults, speedConfig.min_download_mb_s)}`;
830
1030
  await writeReport();
831
- throw new Error('No links passed speed test');
1031
+ throw new Error(speedtestFailureMessage(orderedFullResults, speedConfig.min_download_mb_s));
832
1032
  }
833
1033
  await setStage('speedtest', 'success');
834
1034
  summary.run_status = 'success';
@@ -978,6 +1178,7 @@ export async function resumeNodePipeline(options, context = {}) {
978
1178
  await setStage('verify', 'skipped');
979
1179
  }
980
1180
  else {
1181
+ assertDeployMinimumFinalLinks(profile, postprocessed.links);
981
1182
  await setStage('deploy', 'running');
982
1183
  const deployment = context.stages?.deploy
983
1184
  ? await context.stages.deploy({ projectRoot, bundleDir, profile })
@@ -150,11 +150,9 @@ export function runPostprocess(input) {
150
150
  };
151
151
  }
152
152
  export function selectPipelineStageBackend(stage, env = process.env) {
153
- const stageKey = `AUTOVPN_STAGE_BACKEND_${stage.toUpperCase()}`;
154
- const stageOverride = String(env[stageKey] ?? '').trim().toLowerCase();
155
- const pipelineOverride = String(env.AUTOVPN_PIPELINE_BACKEND ?? '').trim().toLowerCase();
156
- const selected = stageOverride || pipelineOverride || 'node';
157
- return selected === 'python' ? 'python' : 'node';
153
+ void stage;
154
+ void env;
155
+ return 'node';
158
156
  }
159
157
  async function defaultResolvePythonCli(env) {
160
158
  // @ts-expect-error Phase 1 runner remains plain ESM JavaScript.
@@ -1,5 +1,5 @@
1
1
  import { spawn as defaultSpawn } from 'node:child_process';
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import net from 'node:net';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
@@ -13,6 +13,7 @@ export const PROXY_ENV_KEYS = [
13
13
  'NO_PROXY',
14
14
  'no_proxy'
15
15
  ];
16
+ const MIHOMO_DELAY_TIMEOUT_MAX_MS = 30_000;
16
17
  function padBase64(encoded) {
17
18
  return encoded + '='.repeat((4 - (encoded.length % 4)) % 4);
18
19
  }
@@ -99,7 +100,8 @@ export async function selectMihomoProxy(controllerUrl, proxyName, _timeoutSecond
99
100
  export async function probeMihomoProxyDelay(controllerUrl, proxyName, probeUrl, timeoutSeconds, options = {}) {
100
101
  const fetchImpl = requireFetch(options.fetch);
101
102
  const url = new URL(`${controllerUrl}/proxies/${encodeURIComponent(proxyName)}/delay`);
102
- url.searchParams.set('timeout', String(Math.trunc(timeoutSeconds * 1000)));
103
+ const timeoutMs = Math.min(MIHOMO_DELAY_TIMEOUT_MAX_MS, Math.max(1, Math.trunc(timeoutSeconds * 1000)));
104
+ url.searchParams.set('timeout', String(timeoutMs));
103
105
  url.searchParams.set('url', probeUrl);
104
106
  const response = await fetchImpl(url.toString(), { method: 'GET' });
105
107
  requireOkResponse(response, 'mihomo proxy delay probe');
@@ -176,6 +178,27 @@ async function closeChildProcess(process) {
176
178
  process.kill('SIGTERM');
177
179
  });
178
180
  }
181
+ async function resolveMihomoCommand(runtimePath) {
182
+ const candidate = String(runtimePath ?? '').trim();
183
+ if (!candidate) {
184
+ return 'mihomo';
185
+ }
186
+ try {
187
+ const info = await stat(candidate);
188
+ if (info.isFile()) {
189
+ return candidate;
190
+ }
191
+ if (info.isDirectory() && path.basename(candidate) === 'runtime') {
192
+ return 'mihomo';
193
+ }
194
+ }
195
+ catch {
196
+ if (path.basename(candidate) === 'runtime') {
197
+ return 'mihomo';
198
+ }
199
+ }
200
+ return candidate;
201
+ }
179
202
  export async function openMihomoRuntime(link, options = {}) {
180
203
  const startupWaitSeconds = Number(options.startupWaitSeconds ?? 1);
181
204
  const mixedPort = Number.isInteger(options.mixedPort) && Number(options.mixedPort) > 0
@@ -191,10 +214,19 @@ export async function openMihomoRuntime(link, options = {}) {
191
214
  const tempDir = await mkdtemp(path.join(os.tmpdir(), 'autovpn-mihomo-'));
192
215
  const configPath = path.join(tempDir, 'config.json');
193
216
  await writeFile(configPath, JSON.stringify(config), 'utf8');
194
- const child = (options.spawn ?? defaultSpawn)(options.runtimePath || 'mihomo', ['-f', configPath], {
217
+ const command = await resolveMihomoCommand(options.runtimePath);
218
+ const child = (options.spawn ?? defaultSpawn)(command, ['-f', configPath], {
195
219
  stdio: ['ignore', 'pipe', 'pipe'],
196
220
  env: stripProxyEnv(options.env ?? process.env)
197
221
  });
222
+ let rejectStartupError;
223
+ const startupError = new Promise((_resolve, reject) => {
224
+ rejectStartupError = reject;
225
+ });
226
+ const onStartupError = (error) => {
227
+ rejectStartupError?.(error);
228
+ };
229
+ child.once('error', onStartupError);
198
230
  let closed = false;
199
231
  const close = async () => {
200
232
  if (closed) {
@@ -206,11 +238,15 @@ export async function openMihomoRuntime(link, options = {}) {
206
238
  };
207
239
  try {
208
240
  const waitForPort = options.waitForPort ?? defaultWaitForPort;
209
- await waitForPort(mixedPort, startupWaitSeconds + 4);
210
- await waitForPort(controllerPort, startupWaitSeconds + 4);
211
- await (options.selectProxy ?? ((url, name, timeoutSeconds) => selectMihomoProxy(url, name, timeoutSeconds)))(controllerUrl, proxyName, startupWaitSeconds + 4);
241
+ await Promise.race([waitForPort(mixedPort, startupWaitSeconds + 4), startupError]);
242
+ await Promise.race([waitForPort(controllerPort, startupWaitSeconds + 4), startupError]);
243
+ await Promise.race([(options.selectProxy ?? ((url, name, timeoutSeconds) => selectMihomoProxy(url, name, timeoutSeconds)))(controllerUrl, proxyName, startupWaitSeconds + 4), startupError]);
244
+ child.off('error', onStartupError);
245
+ child.on('error', () => { });
212
246
  }
213
247
  catch (error) {
248
+ child.off('error', onStartupError);
249
+ child.on('error', () => { });
214
250
  await close();
215
251
  throw error;
216
252
  }
@@ -28,11 +28,9 @@ export function runRender(input) {
28
28
  };
29
29
  }
30
30
  export function selectPipelineStageBackend(stage, env = process.env) {
31
- const stageKey = `AUTOVPN_STAGE_BACKEND_${stage.toUpperCase()}`;
32
- const stageOverride = String(env[stageKey] ?? '').trim().toLowerCase();
33
- const pipelineOverride = String(env.AUTOVPN_PIPELINE_BACKEND ?? '').trim().toLowerCase();
34
- const selected = stageOverride || pipelineOverride || 'node';
35
- return selected === 'python' ? 'python' : 'node';
31
+ void stage;
32
+ void env;
33
+ return 'node';
36
34
  }
37
35
  async function defaultResolvePythonCli(env) {
38
36
  // @ts-expect-error Phase 1 runner remains plain ESM JavaScript.