@swimmingliu/autovpn 2.0.2 → 2.1.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,7 +1,9 @@
1
1
  import http from 'node:http';
2
- import net from 'node:net';
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
- const request = http.request({
408
- hostname: proxy.hostname,
409
- port: Number(proxy.port || 80),
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', reject);
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
- const timer = setTimeout(() => {
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 onError = (error) => {
437
+ const settle = (action) => {
438
+ if (settled) {
439
+ return;
440
+ }
441
+ settled = true;
456
442
  cleanup();
457
- reject(error);
443
+ destroyQuietly(socket);
444
+ action();
458
445
  };
459
- const onEnd = () => {
460
- cleanup();
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
- reject(new Error('proxy response ended before HTTP headers were received'));
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
- const onData = (chunk) => {
468
- if (!headersParsed) {
469
- buffered = Buffer.concat([buffered, chunk]);
470
- const split = splitHttpHeaders(buffered);
471
- if (!split) {
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
- status = parseHttpStatus(split.head);
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
- chunks.push(chunk);
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
- const timer = setTimeout(() => {
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 onError = (error) => {
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 onData = (chunk) => {
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 = splitHttpHeaders(buffered);
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
- socket.destroy();
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
- const timer = setTimeout(() => {
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 onSecureConnect = () => {
538
- cleanup();
539
- resolve();
540
- };
541
- const onError = (error) => {
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 proxyPort = Number(proxy.port || 80);
569
- const socket = await socketConnect(proxy.hostname, proxyPort, timeoutMs);
605
+ const endpoint = parseProxyEndpoint(proxy);
570
606
  const targetPort = Number(target.port || 443);
571
- socket.write([
572
- `CONNECT ${target.hostname}:${targetPort} HTTP/1.1`,
573
- `Host: ${target.hostname}:${targetPort}`,
574
- 'Connection: keep-alive',
575
- '',
576
- ''
577
- ].join('\r\n'));
578
- await readConnectResponse(socket, timeoutMs);
579
- const secureSocket = tls.connect({
580
- socket,
581
- servername: target.hostname,
582
- rejectUnauthorized: true
583
- });
584
- await waitForSecureConnect(secureSocket, timeoutMs);
585
- secureSocket.write([
586
- `GET ${target.pathname || '/'}${target.search} HTTP/1.1`,
587
- `Host: ${target.host}`,
588
- 'Connection: close',
589
- '',
590
- ''
591
- ].join('\r\n'));
592
- const response = await readHttpResponse(secureSocket, timeoutMs);
593
- const proxiedResponse = {
594
- final_url: target.toString(),
595
- status_code: response.status,
596
- body: response.body,
597
- headers: response.headers
598
- };
599
- const next = redirectTarget(target, proxiedResponse);
600
- if (next) {
601
- if (redirectsRemaining <= 0) {
602
- throw new Error(`too many redirects while fetching ${target.toString()}`);
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 fetchUrlViaHttpProxyTarget(next, proxy, timeoutMs, redirectsRemaining - 1);
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 mapWithConcurrency(input.results, numberOrDefault(input.config.concurrency, 1), async (speedResult) => {
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
+ }