@swimmingliu/autovpn 1.6.9 → 1.7.0

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.
@@ -1,6 +1,7 @@
1
1
  import net from 'node:net';
2
2
  import tls from 'node:tls';
3
3
  import { openMihomoRuntime as defaultOpenMihomoRuntime, probeMihomoProxyDelay as defaultProbeMihomoProxyDelay } from './proxy-runtime.js';
4
+ import { retryTransientNetwork } from './network-retry.js';
4
5
  const PROBE_MAX_ATTEMPTS = 2;
5
6
  export function aggregateSpeedMeasurements(values) {
6
7
  if (values.length === 0) {
@@ -160,35 +161,8 @@ function requireOkResponse(response, allowedStatuses) {
160
161
  throw new Error(`unexpected status ${status}`);
161
162
  }
162
163
  }
163
- function isTransientProbeError(error) {
164
- const message = error instanceof Error ? error.message : String(error);
165
- const code = typeof error === 'object' && error !== null && 'code' in error
166
- ? String(error.code ?? '')
167
- : '';
168
- const statusMatch = /(?:unexpected status|failed with status)\s+(\d{3})\b/i.exec(message);
169
- if (statusMatch) {
170
- const status = Number(statusMatch[1]);
171
- return status >= 500 && status <= 599;
172
- }
173
- if (['AUTOVPN_INTERNAL_TIMEOUT', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ECONNREFUSED', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT'].includes(code.toUpperCase())) {
174
- return true;
175
- }
176
- return error instanceof TypeError && message.trim().toLowerCase() === 'fetch failed';
177
- }
178
164
  async function retryTransientProbe(operation) {
179
- let lastError;
180
- for (let attempt = 1; attempt <= PROBE_MAX_ATTEMPTS; attempt += 1) {
181
- try {
182
- return await operation();
183
- }
184
- catch (error) {
185
- lastError = error;
186
- if (attempt >= PROBE_MAX_ATTEMPTS || !isTransientProbeError(error)) {
187
- throw error;
188
- }
189
- }
190
- }
191
- throw lastError;
165
+ return retryTransientNetwork(operation, { maxAttempts: PROBE_MAX_ATTEMPTS, delayMs: 0 });
192
166
  }
193
167
  function socketConnect(host, port, timeoutMs) {
194
168
  return new Promise((resolve, reject) => {
@@ -359,8 +333,14 @@ export async function downloadUrlViaHttpProxy(url, proxyUrl, maxBytes, timeoutSe
359
333
  rejectUnauthorized: false
360
334
  });
361
335
  await new Promise((resolve, reject) => {
362
- secureSocket.once('secureConnect', resolve);
363
- secureSocket.once('error', reject);
336
+ const timer = setTimeout(() => {
337
+ secureSocket.destroy();
338
+ const error = new Error(`TLS handshake timed out after ${timeoutMs}ms`);
339
+ error.code = 'AUTOVPN_INTERNAL_TIMEOUT';
340
+ reject(error);
341
+ }, timeoutMs);
342
+ secureSocket.once('secureConnect', () => { clearTimeout(timer); resolve(); });
343
+ secureSocket.once('error', (error) => { clearTimeout(timer); reject(error); });
364
344
  });
365
345
  secureSocket.write([
366
346
  `GET ${requestPath} HTTP/1.1`,
@@ -461,43 +441,44 @@ async function testLinkDirect(link, config, options) {
461
441
  async function testLinkMihomo(link, config, runtimePath, options) {
462
442
  const openRuntime = options.openMihomoRuntime ?? defaultOpenMihomoRuntime;
463
443
  const downloadViaProxy = options.downloadUrlViaHttpProxy ?? downloadUrlViaHttpProxy;
464
- let runtime;
465
444
  try {
466
- runtime = await openRuntime(link, {
467
- runtimePath,
468
- startupWaitSeconds: config.startup_wait_seconds,
469
- env: options.env
470
- });
471
- const now = options.now ?? defaultNow;
472
- const speedValues = [];
473
- const failures = [];
474
- for (const url of config.urls) {
475
- const started = now();
445
+ return await retryTransientNetwork(async () => {
446
+ let runtime;
476
447
  try {
477
- const total = await downloadViaProxy(url, runtime.proxies.http, Math.max(1, Number(config.max_download_bytes)), config.timeout_seconds);
478
- const elapsedSeconds = Math.max((now() - started) / 1000, 0.001);
479
- speedValues.push(total / elapsedSeconds / 1024 / 1024);
448
+ runtime = await openRuntime(link, {
449
+ runtimePath,
450
+ startupWaitSeconds: config.startup_wait_seconds,
451
+ env: options.env
452
+ });
453
+ const now = options.now ?? defaultNow;
454
+ const speedValues = [];
455
+ const failures = [];
456
+ let lastFailure;
457
+ for (const url of config.urls) {
458
+ try {
459
+ const measurement = await retryTransientNetwork(async () => {
460
+ const started = now();
461
+ const total = await downloadViaProxy(url, runtime.proxies.http, Math.max(1, Number(config.max_download_bytes)), config.timeout_seconds);
462
+ return { total, elapsedSeconds: Math.max((now() - started) / 1000, 0.001) };
463
+ });
464
+ speedValues.push(measurement.total / measurement.elapsedSeconds / 1024 / 1024);
465
+ }
466
+ catch (error) {
467
+ lastFailure = error;
468
+ failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`);
469
+ }
470
+ }
471
+ if (speedValues.length === 0) {
472
+ if (lastFailure)
473
+ throw lastFailure;
474
+ return { link, reachable: false, average_download_mb_s: 0, latency_ms: 0, error: failures.join('; ') || 'all speed test urls failed' };
475
+ }
476
+ return { link, reachable: true, average_download_mb_s: aggregateSpeedMeasurements(speedValues), latency_ms: 0, error: failures.join('; ') };
480
477
  }
481
- catch (error) {
482
- failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`);
478
+ finally {
479
+ await runtime?.close();
483
480
  }
484
- }
485
- if (speedValues.length === 0) {
486
- return {
487
- link,
488
- reachable: false,
489
- average_download_mb_s: 0,
490
- latency_ms: 0,
491
- error: failures.join('; ') || 'all speed test urls failed'
492
- };
493
- }
494
- return {
495
- link,
496
- reachable: true,
497
- average_download_mb_s: aggregateSpeedMeasurements(speedValues),
498
- latency_ms: 0,
499
- error: failures.join('; ')
500
- };
481
+ });
501
482
  }
502
483
  catch (error) {
503
484
  return {
@@ -508,9 +489,6 @@ async function testLinkMihomo(link, config, runtimePath, options) {
508
489
  error: error instanceof Error ? error.message : String(error)
509
490
  };
510
491
  }
511
- finally {
512
- await runtime?.close();
513
- }
514
492
  }
515
493
  async function speedtestInNode(input, options) {
516
494
  if (input.links.length === 0) {
@@ -0,0 +1,154 @@
1
+ export class AsyncPermitPool {
2
+ available;
3
+ waiters = [];
4
+ constructor(limit) {
5
+ if (!Number.isInteger(limit) || limit <= 0)
6
+ throw new RangeError('permit limit must be a positive integer');
7
+ this.available = limit;
8
+ }
9
+ async run(operation) {
10
+ await this.acquire();
11
+ try {
12
+ return await operation();
13
+ }
14
+ finally {
15
+ this.release();
16
+ }
17
+ }
18
+ async acquire() {
19
+ if (this.available > 0) {
20
+ this.available -= 1;
21
+ return;
22
+ }
23
+ await new Promise((resolve) => this.waiters.push(resolve));
24
+ }
25
+ release() {
26
+ const waiter = this.waiters.shift();
27
+ if (waiter)
28
+ waiter();
29
+ else
30
+ this.available += 1;
31
+ }
32
+ }
33
+ export class BoundedWorkerPool {
34
+ concurrency;
35
+ limit;
36
+ worker;
37
+ queue = [];
38
+ admissionWaiters = [];
39
+ drainWaiters = [];
40
+ active = 0;
41
+ closed = false;
42
+ failure;
43
+ failed = false;
44
+ constructor(options) {
45
+ if (!Number.isInteger(options.concurrency) || options.concurrency <= 0) {
46
+ throw new RangeError('concurrency must be a positive integer');
47
+ }
48
+ if (!Number.isInteger(options.capacity) || options.capacity < 0) {
49
+ throw new RangeError('capacity must be a non-negative integer');
50
+ }
51
+ this.concurrency = options.concurrency;
52
+ this.limit = options.concurrency + options.capacity;
53
+ this.worker = options.worker;
54
+ }
55
+ submit(item) {
56
+ if (this.failed) {
57
+ return Promise.reject(this.failure);
58
+ }
59
+ if (this.closed) {
60
+ return Promise.reject(new Error('worker pool is closed'));
61
+ }
62
+ if (this.active + this.queue.length < this.limit) {
63
+ this.accept(item);
64
+ return Promise.resolve();
65
+ }
66
+ return new Promise((resolve, reject) => {
67
+ this.admissionWaiters.push({ item, resolve, reject });
68
+ });
69
+ }
70
+ close() {
71
+ this.closed = true;
72
+ this.settleDrainWaitersIfIdle();
73
+ }
74
+ drain() {
75
+ if (this.isIdle()) {
76
+ return this.failed ? Promise.reject(this.failure) : Promise.resolve();
77
+ }
78
+ return new Promise((resolve, reject) => {
79
+ this.drainWaiters.push({ resolve, reject });
80
+ });
81
+ }
82
+ abort(error) {
83
+ if (!this.failed) {
84
+ this.failed = true;
85
+ this.failure = error;
86
+ }
87
+ this.closed = true;
88
+ this.queue.length = 0;
89
+ this.rejectAdmissionWaiters(this.failure);
90
+ this.settleDrainWaitersIfIdle();
91
+ }
92
+ accept(item) {
93
+ if (this.active < this.concurrency) {
94
+ this.start(item);
95
+ }
96
+ else {
97
+ this.queue.push(item);
98
+ }
99
+ }
100
+ start(item) {
101
+ this.active += 1;
102
+ Promise.resolve()
103
+ .then(() => this.worker(item))
104
+ .catch((error) => this.recordFailure(error))
105
+ .finally(() => {
106
+ this.active -= 1;
107
+ if (!this.failed) {
108
+ this.fillAvailableSlots();
109
+ }
110
+ this.settleDrainWaitersIfIdle();
111
+ });
112
+ }
113
+ fillAvailableSlots() {
114
+ while (this.active < this.concurrency && this.queue.length > 0) {
115
+ this.start(this.queue.shift());
116
+ }
117
+ while (this.active + this.queue.length < this.limit && this.admissionWaiters.length > 0) {
118
+ const admission = this.admissionWaiters.shift();
119
+ this.accept(admission.item);
120
+ admission.resolve();
121
+ }
122
+ }
123
+ recordFailure(error) {
124
+ if (this.failed) {
125
+ return;
126
+ }
127
+ this.failed = true;
128
+ this.failure = error;
129
+ this.closed = true;
130
+ this.queue.length = 0;
131
+ this.rejectAdmissionWaiters(error);
132
+ }
133
+ rejectAdmissionWaiters(error) {
134
+ for (const admission of this.admissionWaiters.splice(0)) {
135
+ admission.reject(error);
136
+ }
137
+ }
138
+ isIdle() {
139
+ return this.active === 0 && this.queue.length === 0;
140
+ }
141
+ settleDrainWaitersIfIdle() {
142
+ if (!this.isIdle()) {
143
+ return;
144
+ }
145
+ for (const waiter of this.drainWaiters.splice(0)) {
146
+ if (this.failed) {
147
+ waiter.reject(this.failure);
148
+ }
149
+ else {
150
+ waiter.resolve();
151
+ }
152
+ }
153
+ }
154
+ }
@@ -1187,7 +1187,7 @@ function handlePipelineEvent(event, options = {}) {
1187
1187
  }
1188
1188
 
1189
1189
  if (event.type === 'stage') {
1190
- state.stageStatus[event.stage] = event.status;
1190
+ state.stageStatus[event.stage] = normalizeStageStatus(event.status);
1191
1191
  appendLog(`[stage] ${event.stage} ${event.status}`, {
1192
1192
  kind: 'stage',
1193
1193
  stage: event.stage,
@@ -1199,7 +1199,7 @@ function handlePipelineEvent(event, options = {}) {
1199
1199
  }
1200
1200
 
1201
1201
  if (event.type === 'summary') {
1202
- state.stageStatus = event.stage_status ?? {};
1202
+ state.stageStatus = normalizeStageStatuses(event.stage_status);
1203
1203
  state.counts = normalizeCounts(event.counts ?? {});
1204
1204
  state.sourceCounts = normalizeSourceCounts(event.source_counts ?? state.sourceCounts);
1205
1205
  state.retryContext = event.retry_context ?? state.retryContext ?? {};
@@ -1332,12 +1332,38 @@ function handlePipelineEvent(event, options = {}) {
1332
1332
  }
1333
1333
 
1334
1334
  if (event.type === 'run_started') {
1335
- state.artifactDir = event.artifact_dir ?? '';
1335
+ const nextArtifactDir = String(event.artifact_dir ?? '');
1336
+ if (nextArtifactDir !== state.artifactDir) {
1337
+ resetRunScopedProgress();
1338
+ state.runState = 'running';
1339
+ state.runResult = 'running';
1340
+ state.runStartedAt = Date.now();
1341
+ }
1342
+ state.artifactDir = nextArtifactDir;
1336
1343
  touchUpdate();
1337
1344
  renderAll();
1338
1345
  }
1339
1346
  }
1340
1347
 
1348
+ function normalizeStageStatus(status) {
1349
+ return status === 'skipped' ? '已跳过' : status;
1350
+ }
1351
+
1352
+ function normalizeStageStatuses(stageStatuses = {}) {
1353
+ return Object.fromEntries(
1354
+ Object.entries(stageStatuses ?? {}).map(([stage, status]) => [stage, normalizeStageStatus(status)])
1355
+ );
1356
+ }
1357
+
1358
+ function resetRunScopedProgress() {
1359
+ state.stageStatus = {};
1360
+ state.counts = {};
1361
+ state.sourceCounts = {};
1362
+ state.extractDedupedFingerprints = new Set();
1363
+ state.outputFiles = [];
1364
+ state.nodeRows = [];
1365
+ }
1366
+
1341
1367
  function normalizeCounts(counts) {
1342
1368
  return {
1343
1369
  ...counts,
@@ -1368,8 +1394,16 @@ function updateExtractMetrics(event) {
1368
1394
  }
1369
1395
 
1370
1396
  const previous = state.sourceCounts[sourceName] ?? {};
1371
- const rawLinks = Number(event.total_links ?? previous.raw_links ?? 0);
1372
- const sourceDedupedLinks = Number(event.deduped_links ?? previous.deduped_links ?? rawLinks);
1397
+ const previousRawLinks = validNonNegativeNumber(previous.raw_links) ?? 0;
1398
+ const previousDedupedLinks = validNonNegativeNumber(previous.deduped_links) ?? 0;
1399
+ const rawLinks = Math.max(previousRawLinks, validNonNegativeNumber(event.total_links) ?? previousRawLinks);
1400
+ const eventDedupedLinks = event.deduped_links == null
1401
+ ? rawLinks
1402
+ : validNonNegativeNumber(event.deduped_links) ?? previousDedupedLinks;
1403
+ const sourceDedupedLinks = Math.max(
1404
+ previousDedupedLinks,
1405
+ eventDedupedLinks
1406
+ );
1373
1407
  if (Array.isArray(event.new_item_fingerprints)) {
1374
1408
  for (const fingerprint of event.new_item_fingerprints) {
1375
1409
  if (fingerprint) {
@@ -1395,6 +1429,11 @@ function updateExtractMetrics(event) {
1395
1429
  }
1396
1430
  }
1397
1431
 
1432
+ function validNonNegativeNumber(value) {
1433
+ const number = Number(value);
1434
+ return Number.isFinite(number) && number >= 0 ? number : null;
1435
+ }
1436
+
1398
1437
  async function hydrateArtifactPreview() {
1399
1438
  if (!state.artifactDir || !window.vpnAutomation?.previewArtifact) {
1400
1439
  state.outputFiles = [];
@@ -1430,7 +1469,7 @@ function hydrateArtifactState(result) {
1430
1469
  state.runResult = 'failed';
1431
1470
  }
1432
1471
  if (result.stage_status) {
1433
- state.stageStatus = result.stage_status;
1472
+ state.stageStatus = normalizeStageStatuses(result.stage_status);
1434
1473
  }
1435
1474
  }
1436
1475
 
@@ -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.9',
8
+ sidebarVersion: 'v.1.7.0',
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.9",
3
+ "version": "1.7.0",
4
4
  "description": "npm wrapper for the AutoVPN headless CLI",
5
5
  "type": "module",
6
6
  "repository": {