@swimmingliu/autovpn 1.6.9 β†’ 1.8.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,3 +1,4 @@
1
+ import { isIsoAlpha2CountryCode } from './country-codes.js';
1
2
  const EMOJI_MAP = {
2
3
  AE: 'πŸ‡¦πŸ‡ͺ',
3
4
  AR: 'πŸ‡¦πŸ‡·',
@@ -37,7 +38,6 @@ const EMOJI_MAP = {
37
38
  ZA: 'πŸ‡ΏπŸ‡¦'
38
39
  };
39
40
  const FALLBACK_COUNTRY_CODE = 'US';
40
- const UNKNOWN_COUNTRY_CODE = 'ZZ';
41
41
  const DEFAULT_FILTERS = {
42
42
  excluded_country_codes: ['CN'],
43
43
  per_country_limit: {}
@@ -55,7 +55,7 @@ export function generateVmessLink(payload) {
55
55
  }
56
56
  export function normalizeCountryCode(countryCode) {
57
57
  const normalized = String(countryCode || '').trim().toUpperCase();
58
- if (normalized.length !== 2 || !/^[A-Z]{2}$/.test(normalized) || normalized === UNKNOWN_COUNTRY_CODE) {
58
+ if (!isIsoAlpha2CountryCode(normalized)) {
59
59
  return FALLBACK_COUNTRY_CODE;
60
60
  }
61
61
  return normalized;
@@ -14,6 +14,7 @@ export const PROXY_ENV_KEYS = [
14
14
  'no_proxy'
15
15
  ];
16
16
  const MIHOMO_DELAY_TIMEOUT_MAX_MS = 30_000;
17
+ const reservedAutomaticPorts = new Set();
17
18
  function padBase64(encoded) {
18
19
  return encoded + '='.repeat((4 - (encoded.length % 4)) % 4);
19
20
  }
@@ -85,16 +86,45 @@ function requireOkResponse(response, context) {
85
86
  throw new Error(`${context} failed with status ${response.status ?? 0} ${response.statusText ?? ''}`.trim());
86
87
  }
87
88
  }
88
- export async function selectMihomoProxy(controllerUrl, proxyName, _timeoutSeconds, options = {}) {
89
+ async function fetchControllerWithTimeout(fetchImpl, url, init, timeoutSeconds) {
90
+ const controller = new AbortController();
91
+ let timedOut = false;
92
+ const timeoutMs = Math.max(1, Math.trunc(timeoutSeconds * 1000));
93
+ let timer;
94
+ const timeout = new Promise((_resolve, reject) => {
95
+ timer = setTimeout(() => {
96
+ timedOut = true;
97
+ const timeoutError = new Error(`mihomo controller request timed out after ${timeoutMs}ms`);
98
+ timeoutError.code = 'AUTOVPN_INTERNAL_TIMEOUT';
99
+ reject(timeoutError);
100
+ controller.abort();
101
+ }, timeoutMs);
102
+ });
103
+ try {
104
+ return await Promise.race([fetchImpl(url, { ...init, signal: controller.signal }), timeout]);
105
+ }
106
+ catch (error) {
107
+ if (!timedOut || (error instanceof Error && error.code === 'AUTOVPN_INTERNAL_TIMEOUT'))
108
+ throw error;
109
+ const timeoutError = new Error(`mihomo controller request timed out after ${timeoutMs}ms`, { cause: error });
110
+ timeoutError.code = 'AUTOVPN_INTERNAL_TIMEOUT';
111
+ throw timeoutError;
112
+ }
113
+ finally {
114
+ if (timer)
115
+ clearTimeout(timer);
116
+ }
117
+ }
118
+ export async function selectMihomoProxy(controllerUrl, proxyName, timeoutSeconds, options = {}) {
89
119
  if (!controllerUrl) {
90
120
  return;
91
121
  }
92
122
  const fetchImpl = requireFetch(options.fetch);
93
- const response = await fetchImpl(`${controllerUrl}/proxies/GLOBAL`, {
123
+ const response = await fetchControllerWithTimeout(fetchImpl, `${controllerUrl}/proxies/GLOBAL`, {
94
124
  method: 'PUT',
95
125
  headers: { 'content-type': 'application/json' },
96
126
  body: JSON.stringify({ name: proxyName })
97
- });
127
+ }, timeoutSeconds);
98
128
  requireOkResponse(response, 'mihomo proxy selection');
99
129
  }
100
130
  export async function probeMihomoProxyDelay(controllerUrl, proxyName, probeUrl, timeoutSeconds, options = {}) {
@@ -103,7 +133,7 @@ export async function probeMihomoProxyDelay(controllerUrl, proxyName, probeUrl,
103
133
  const timeoutMs = Math.min(MIHOMO_DELAY_TIMEOUT_MAX_MS, Math.max(1, Math.trunc(timeoutSeconds * 1000)));
104
134
  url.searchParams.set('timeout', String(timeoutMs));
105
135
  url.searchParams.set('url', probeUrl);
106
- const response = await fetchImpl(url.toString(), { method: 'GET' });
136
+ const response = await fetchControllerWithTimeout(fetchImpl, url.toString(), { method: 'GET' }, timeoutSeconds);
107
137
  requireOkResponse(response, 'mihomo proxy delay probe');
108
138
  const payload = await response.json?.();
109
139
  const delay = Number(payload?.delay ?? -1);
@@ -132,6 +162,19 @@ async function findFreePort() {
132
162
  });
133
163
  });
134
164
  }
165
+ async function reserveAutomaticPort(allocatePort) {
166
+ for (let attempt = 0; attempt < 100; attempt += 1) {
167
+ const port = await allocatePort();
168
+ if (!Number.isInteger(port) || port <= 0) {
169
+ throw new Error(`invalid automatically allocated port: ${port}`);
170
+ }
171
+ if (!reservedAutomaticPorts.has(port)) {
172
+ reservedAutomaticPorts.add(port);
173
+ return port;
174
+ }
175
+ }
176
+ throw new Error('unable to reserve a unique local port after 100 attempts');
177
+ }
135
178
  async function canConnectToPort(port) {
136
179
  return await new Promise((resolve) => {
137
180
  const socket = net.createConnection({ host: '127.0.0.1', port });
@@ -164,18 +207,29 @@ async function closeChildProcess(process) {
164
207
  if (process.exitCode !== null || process.killed) {
165
208
  return;
166
209
  }
167
- await new Promise((resolve) => {
210
+ await new Promise((resolve, reject) => {
168
211
  const timer = setTimeout(() => {
169
- if (process.exitCode === null && !process.killed) {
170
- process.kill('SIGKILL');
212
+ try {
213
+ if (process.exitCode === null && !process.killed) {
214
+ process.kill('SIGKILL');
215
+ }
216
+ resolve();
217
+ }
218
+ catch (error) {
219
+ reject(error);
171
220
  }
172
- resolve();
173
221
  }, 2000);
174
222
  process.once('close', () => {
175
223
  clearTimeout(timer);
176
224
  resolve();
177
225
  });
178
- process.kill('SIGTERM');
226
+ try {
227
+ process.kill('SIGTERM');
228
+ }
229
+ catch (error) {
230
+ clearTimeout(timer);
231
+ reject(error);
232
+ }
179
233
  });
180
234
  }
181
235
  async function fileExists(candidate) {
@@ -227,24 +281,54 @@ async function resolveMihomoCommand(runtimePath, env = process.env) {
227
281
  }
228
282
  export async function openMihomoRuntime(link, options = {}) {
229
283
  const startupWaitSeconds = Number(options.startupWaitSeconds ?? 1);
284
+ const payload = parseVmessLink(link);
285
+ const automaticPorts = [];
286
+ const allocatePort = options.allocatePort ?? findFreePort;
230
287
  const mixedPort = Number.isInteger(options.mixedPort) && Number(options.mixedPort) > 0
231
288
  ? Number(options.mixedPort)
232
- : await findFreePort();
233
- const controllerPort = Number.isInteger(options.controllerPort) && Number(options.controllerPort) > 0
234
- ? Number(options.controllerPort)
235
- : await findFreePort();
289
+ : await reserveAutomaticPort(allocatePort);
290
+ if (!(Number.isInteger(options.mixedPort) && Number(options.mixedPort) > 0))
291
+ automaticPorts.push(mixedPort);
292
+ let controllerPort;
293
+ try {
294
+ controllerPort = Number.isInteger(options.controllerPort) && Number(options.controllerPort) > 0
295
+ ? Number(options.controllerPort)
296
+ : await reserveAutomaticPort(allocatePort);
297
+ if (!(Number.isInteger(options.controllerPort) && Number(options.controllerPort) > 0))
298
+ automaticPorts.push(controllerPort);
299
+ }
300
+ catch (error) {
301
+ for (const port of automaticPorts)
302
+ reservedAutomaticPorts.delete(port);
303
+ throw error;
304
+ }
236
305
  const proxyName = 'runtime-node';
237
306
  const controllerUrl = `http://127.0.0.1:${controllerPort}`;
238
- const payload = parseVmessLink(link);
239
- const config = buildMihomoRuntimeConfig(payload, { mixedPort, controllerPort });
240
- const tempDir = await mkdtemp(path.join(os.tmpdir(), 'autovpn-mihomo-'));
241
- const configPath = path.join(tempDir, 'config.json');
242
- await writeFile(configPath, JSON.stringify(config), 'utf8');
243
- const command = await resolveMihomoCommand(options.runtimePath, options.env ?? process.env);
244
- const child = (options.spawn ?? defaultSpawn)(command, ['-f', configPath], {
245
- stdio: ['ignore', 'pipe', 'pipe'],
246
- env: stripProxyEnv(options.env ?? process.env)
247
- });
307
+ let tempDir = '';
308
+ let configPath = '';
309
+ let child;
310
+ try {
311
+ const config = buildMihomoRuntimeConfig(payload, { mixedPort, controllerPort });
312
+ tempDir = await mkdtemp(path.join(os.tmpdir(), 'autovpn-mihomo-'));
313
+ configPath = path.join(tempDir, 'config.json');
314
+ await writeFile(configPath, JSON.stringify(config), 'utf8');
315
+ const command = await resolveMihomoCommand(options.runtimePath, options.env ?? process.env);
316
+ child = (options.spawn ?? defaultSpawn)(command, ['-f', configPath], {
317
+ stdio: ['ignore', 'pipe', 'pipe'],
318
+ env: stripProxyEnv(options.env ?? process.env)
319
+ });
320
+ }
321
+ catch (error) {
322
+ try {
323
+ if (tempDir)
324
+ await rm(tempDir, { recursive: true, force: true });
325
+ }
326
+ finally {
327
+ for (const port of automaticPorts)
328
+ reservedAutomaticPorts.delete(port);
329
+ }
330
+ throw error;
331
+ }
248
332
  let rejectStartupError;
249
333
  const startupError = new Promise((_resolve, reject) => {
250
334
  rejectStartupError = reject;
@@ -253,25 +337,43 @@ export async function openMihomoRuntime(link, options = {}) {
253
337
  rejectStartupError?.(error);
254
338
  };
255
339
  child.once('error', onStartupError);
340
+ let rejectStartupExit;
341
+ const startupExit = new Promise((_resolve, reject) => {
342
+ rejectStartupExit = reject;
343
+ });
344
+ const onStartupExit = (code, signal) => {
345
+ const error = new Error(`mihomo exited during startup with code ${code ?? 'unknown'}${signal ? ` signal ${signal}` : ''}`);
346
+ error.code = 'AUTOVPN_INTERNAL_TIMEOUT';
347
+ rejectStartupExit?.(error);
348
+ };
349
+ child.once('exit', onStartupExit);
256
350
  let closed = false;
257
351
  const close = async () => {
258
352
  if (closed) {
259
353
  return;
260
354
  }
261
355
  closed = true;
262
- await closeChildProcess(child);
263
- await rm(tempDir, { recursive: true, force: true });
356
+ try {
357
+ await closeChildProcess(child);
358
+ await rm(tempDir, { recursive: true, force: true });
359
+ }
360
+ finally {
361
+ for (const port of automaticPorts)
362
+ reservedAutomaticPorts.delete(port);
363
+ }
264
364
  };
265
365
  try {
266
366
  const waitForPort = options.waitForPort ?? defaultWaitForPort;
267
- await Promise.race([waitForPort(mixedPort, startupWaitSeconds + 4), startupError]);
268
- await Promise.race([waitForPort(controllerPort, startupWaitSeconds + 4), startupError]);
269
- await Promise.race([(options.selectProxy ?? ((url, name, timeoutSeconds) => selectMihomoProxy(url, name, timeoutSeconds)))(controllerUrl, proxyName, startupWaitSeconds + 4), startupError]);
367
+ await Promise.race([waitForPort(mixedPort, startupWaitSeconds + 4), startupError, startupExit]);
368
+ await Promise.race([waitForPort(controllerPort, startupWaitSeconds + 4), startupError, startupExit]);
369
+ await Promise.race([(options.selectProxy ?? ((url, name, timeoutSeconds) => selectMihomoProxy(url, name, timeoutSeconds)))(controllerUrl, proxyName, startupWaitSeconds + 4), startupError, startupExit]);
270
370
  child.off('error', onStartupError);
371
+ child.off('exit', onStartupExit);
271
372
  child.on('error', () => { });
272
373
  }
273
374
  catch (error) {
274
375
  child.off('error', onStartupError);
376
+ child.off('exit', onStartupExit);
275
377
  child.on('error', () => { });
276
378
  await close();
277
379
  if (error instanceof Error