@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.
package/README.md CHANGED
@@ -65,6 +65,11 @@ autovpn run --project-root . --output jsonl
65
65
 
66
66
  Runtime notes:
67
67
 
68
+ - Each run writes `artifact_dir/run.db`. This SQLite database is the authoritative local record for run state, node identity, stage results, and resume checkpoints. The `.txt` and `.json` files in the artifact directory are compatibility exports; do not use them as the source of truth for an in-progress run.
69
+ - Pipeline mode sends each newly discovered unique node from the extract callback to a reachability probe. Only reachable nodes receive the full speed measurement, and only nodes that pass the configured speed threshold proceed to availability checks. Dedupe, probe/speedtest, and availability can be active while extraction is still running, so their displayed totals can increase during the run.
70
+ - The streaming queues use bounded concurrency and backpressure. A slow speed or availability worker therefore limits queued work instead of allowing unbounded memory growth.
71
+ - `resume pipeline`, `resume speedtest`, `run --resume-latest`, and `retry-stage` continue from the SQLite checkpoints. Existing legacy artifact directories can be imported on resume; subsequent state is recorded in `run.db` while compatibility exports remain available.
72
+ - Pipeline mode does not apply the legacy global `max_download_candidates` ranking gate before availability. If that field remains in a profile, it only applies when the speed module is run independently in its legacy ranked-candidate mode.
68
73
  - Detached job management runs in Node for `run --detach`, `jobs resume --detach`, and `jobs retry --detach`; detached run/resume/retry workers also use the Node CLI worker.
69
74
  - Non-detached `retry-stage` runs through the Node backend for retryable artifact stages from `speedtest` through `verify`; non-detached `resume pipeline`, `resume speedtest`, and `run --resume-latest` continue existing sessions through the Node backend.
70
75
  - Add `--skip-deploy --skip-verify` when you want an offline Node pipeline check.
@@ -1,9 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { createRequire } from 'node:module';
4
3
  import { resumeNodePipeline, retryNodePipelineStage, runNodePipeline } from '../pipeline/orchestrator.js';
5
4
  import { resolveArtifactsRoot } from '../runtime/paths.js';
6
- const require = createRequire(import.meta.url);
5
+ import { readLatestStageStatuses, readRunStatus } from '../pipeline/run-store.js';
7
6
  function unsupported(method) {
8
7
  return new Error(`Node backend ${method} is not available in this execution path`);
9
8
  }
@@ -12,7 +11,6 @@ function latestIncompleteRunArtifact(projectRoot, env) {
12
11
  if (!fs.existsSync(artifactsRoot)) {
13
12
  return undefined;
14
13
  }
15
- const { DatabaseSync } = require('node:sqlite');
16
14
  const candidates = fs.readdirSync(artifactsRoot, { withFileTypes: true })
17
15
  .filter((entry) => entry.isDirectory())
18
16
  .map((entry) => {
@@ -23,27 +21,14 @@ function latestIncompleteRunArtifact(projectRoot, env) {
23
21
  .filter((candidate) => fs.existsSync(candidate.dbPath))
24
22
  .sort((left, right) => fs.statSync(right.artifactDir).mtimeMs - fs.statSync(left.artifactDir).mtimeMs);
25
23
  for (const candidate of candidates) {
26
- let db;
27
- try {
28
- db = new DatabaseSync(candidate.dbPath);
29
- const runRow = db.prepare('SELECT status FROM runs ORDER BY run_id DESC LIMIT 1').get();
30
- const runStatus = String(runRow?.status ?? '').trim();
31
- if (['success', 'failed', 'stopped'].includes(runStatus)) {
32
- continue;
33
- }
34
- const stageRows = db.prepare('SELECT stage_name, status FROM stage_events ORDER BY rowid ASC').all();
35
- const stageStatus = new Map(stageRows.map((row) => [String(row.stage_name ?? ''), String(row.status ?? '')]));
36
- if (stageStatus.get('verify') === 'success') {
37
- continue;
38
- }
39
- return candidate.artifactDir;
40
- }
41
- catch {
24
+ const runStatus = readRunStatus(candidate.dbPath);
25
+ if (!runStatus || ['success', 'failed', 'stopped', 'cancelled'].includes(runStatus)) {
42
26
  continue;
43
27
  }
44
- finally {
45
- db?.close();
46
- }
28
+ const stageStatus = readLatestStageStatuses(candidate.dbPath);
29
+ if (stageStatus.verify === 'success')
30
+ continue;
31
+ return candidate.artifactDir;
47
32
  }
48
33
  return undefined;
49
34
  }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { RunStore, readRunStatus } from '../pipeline/run-store.js';
3
4
  import { randomBytes } from 'node:crypto';
4
5
  import { spawn as defaultSpawn } from 'node:child_process';
5
6
  import { fileURLToPath } from 'node:url';
@@ -45,18 +46,47 @@ function spawnDetached(command, args, job, options) {
45
46
  function markArtifactStopped(job) {
46
47
  const artifactDir = String(job.artifact_dir ?? '');
47
48
  if (!artifactDir) {
48
- return;
49
+ return undefined;
49
50
  }
50
51
  const reportPath = path.join(artifactDir, 'pipeline_report.json');
51
- if (!fs.existsSync(reportPath)) {
52
- return;
52
+ const dbPath = path.join(artifactDir, 'run.db');
53
+ let authoritativeStatus;
54
+ if (fs.existsSync(dbPath)) {
55
+ let runStore;
56
+ try {
57
+ runStore = RunStore.open(dbPath);
58
+ if (!runStore.stopForResume('Stopped by user'))
59
+ authoritativeStatus = readRunStatus(dbPath);
60
+ }
61
+ catch {
62
+ // The compatibility report remains the fallback when SQLite is corrupt or locked.
63
+ }
64
+ finally {
65
+ try {
66
+ runStore?.close();
67
+ }
68
+ catch { /* best effort */ }
69
+ }
70
+ }
71
+ if (authoritativeStatus) {
72
+ if (fs.existsSync(reportPath)) {
73
+ try {
74
+ const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
75
+ report.run_status = authoritativeStatus;
76
+ fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
77
+ }
78
+ catch { /* SQLite remains authoritative */ }
79
+ }
80
+ return authoritativeStatus;
53
81
  }
82
+ if (!fs.existsSync(reportPath))
83
+ return 'stopped';
54
84
  try {
55
85
  const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
56
86
  const stageStatus = { ...(report.stage_status ?? {}) };
57
87
  for (const [stage, status] of Object.entries(stageStatus)) {
58
88
  if (status === 'running') {
59
- stageStatus[stage] = 'failed';
89
+ stageStatus[stage] = 'stopped';
60
90
  }
61
91
  }
62
92
  report.stage_status = stageStatus;
@@ -67,6 +97,7 @@ function markArtifactStopped(job) {
67
97
  catch {
68
98
  // Best-effort cleanup so stopping a job never fails because its report is corrupt.
69
99
  }
100
+ return 'stopped';
70
101
  }
71
102
  export async function startDetachedRun(command, options = {}) {
72
103
  const outputFormat = command.outputFormat ?? 'jsonl';
@@ -184,10 +215,10 @@ export async function stopManagedJob(projectRoot, jobId, options = {}) {
184
215
  throw new Error(`refusing to stop pid ${pid}: command does not match AutoVPN job`);
185
216
  }
186
217
  await terminateProcessGroup(pid, options);
187
- markArtifactStopped(job);
188
- job.status = 'stopped';
218
+ const artifactStatus = markArtifactStopped(job);
219
+ job.status = artifactStatus === 'success' || artifactStatus === 'failed' ? artifactStatus : 'stopped';
189
220
  job.finished_at = options.now?.() ?? new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00');
190
- job.exit_code = 1;
221
+ job.exit_code = job.status === 'success' ? 0 : 1;
191
222
  job.signal = 'SIGTERM';
192
223
  return store.writeJob(job);
193
224
  }
package/dist/jobs/read.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { createRequire } from 'node:module';
4
3
  import { resolveProfilePath, readOptionValue } from '../runtime/paths.js';
5
4
  import { redactText } from '../runtime/redaction.js';
5
+ import { readRunStatus } from '../pipeline/run-store.js';
6
6
  function jobsRoot(projectRoot, env = process.env) {
7
7
  return path.join(path.dirname(resolveProfilePath(projectRoot, env)), 'jobs');
8
8
  }
@@ -79,29 +79,16 @@ function reconcileFromRunDb(job) {
79
79
  if (!fs.existsSync(runDbPath)) {
80
80
  return undefined;
81
81
  }
82
- try {
83
- const require = createRequire(import.meta.url);
84
- const sqlite = require('node:sqlite');
85
- const db = new sqlite.DatabaseSync(runDbPath);
86
- try {
87
- const row = db.prepare('SELECT status FROM runs ORDER BY run_id DESC LIMIT 1').get();
88
- const runStatus = String(row?.status ?? '');
89
- if (!['success', 'failed', 'stopped'].includes(runStatus)) {
90
- return undefined;
91
- }
92
- return {
93
- status: runStatus,
94
- finished_at: job.finished_at || nowIso(),
95
- exit_code: runStatus === 'success' ? 0 : 1
96
- };
97
- }
98
- finally {
99
- db.close();
100
- }
101
- }
102
- catch {
82
+ const storedStatus = readRunStatus(runDbPath);
83
+ const runStatus = storedStatus === 'cancelled' ? 'stopped' : storedStatus;
84
+ if (!runStatus || !['success', 'failed', 'stopped'].includes(runStatus)) {
103
85
  return undefined;
104
86
  }
87
+ return {
88
+ status: runStatus,
89
+ finished_at: job.finished_at || nowIso(),
90
+ exit_code: runStatus === 'success' ? 0 : 1
91
+ };
105
92
  }
106
93
  function writeJob(job) {
107
94
  const jobFile = String(job.job_file ?? '');
@@ -2,6 +2,7 @@ import http from 'node:http';
2
2
  import net from 'node:net';
3
3
  import tls from 'node:tls';
4
4
  import { openMihomoRuntime as defaultOpenMihomoRuntime } from './proxy-runtime.js';
5
+ import { isTransientNetworkError, retryTransientNetwork } from './network-retry.js';
5
6
  const PROVIDER_TARGETS = [
6
7
  { name: 'gemini', url: 'https://gemini.google.com', allowed_hosts: ['gemini.google.com'], negative_phrases: [] },
7
8
  { name: 'chatgpt_ios', url: 'https://ios.chat.openai.com/', allowed_hosts: ['ios.chat.openai.com'], negative_phrases: [] },
@@ -615,44 +616,45 @@ export async function fetchUrlViaHttpProxy(url, proxyUrl, timeoutSeconds) {
615
616
  async function checkLinkAvailabilityMihomo(speedResult, config, options) {
616
617
  const openRuntime = options.openMihomoRuntime ?? defaultOpenMihomoRuntime;
617
618
  const fetchViaProxy = options.fetchUrlViaHttpProxy ?? fetchUrlViaHttpProxy;
618
- let runtime;
619
- try {
620
- runtime = await openRuntime(speedResult.link, {
621
- runtimePath: options.runtimePath,
622
- startupWaitSeconds: numberOrDefault(config.startup_wait_seconds, 1),
623
- env: options.env
624
- });
625
- const providerResults = {};
626
- const timeoutSeconds = Math.max(1, numberOrDefault(config.timeout_seconds, 20));
627
- for (const target of options.targets) {
628
- try {
629
- const response = await fetchViaProxy(target.url, runtime.proxies.http, timeoutSeconds);
630
- providerResults[target.name] = evaluateProviderResponse(target, {
631
- final_url: response.final_url,
632
- status_code: response.status_code,
633
- title: extractTitle(response.body),
634
- body: response.body
635
- });
636
- }
637
- catch (error) {
638
- providerResults[target.name] = {
639
- provider: target.name,
640
- passed: false,
641
- reason: 'runtime_error',
642
- status_code: 0,
643
- final_url: target.url,
644
- matched_phrase: error instanceof Error ? error.message : String(error)
645
- };
619
+ return retryTransientNetwork(async () => {
620
+ let runtime;
621
+ try {
622
+ runtime = await openRuntime(speedResult.link, {
623
+ runtimePath: options.runtimePath,
624
+ startupWaitSeconds: numberOrDefault(config.startup_wait_seconds, 1),
625
+ env: options.env
626
+ });
627
+ const providerResults = {};
628
+ const timeoutSeconds = Math.max(1, numberOrDefault(config.timeout_seconds, 20));
629
+ for (const target of options.targets) {
630
+ try {
631
+ const response = await retryTransientNetwork(() => fetchViaProxy(target.url, runtime.proxies.http, timeoutSeconds));
632
+ providerResults[target.name] = evaluateProviderResponse(target, {
633
+ final_url: response.final_url,
634
+ status_code: response.status_code,
635
+ title: extractTitle(response.body),
636
+ body: response.body
637
+ });
638
+ }
639
+ catch (error) {
640
+ if (isTransientNetworkError(error))
641
+ throw error;
642
+ providerResults[target.name] = {
643
+ provider: target.name,
644
+ passed: false,
645
+ reason: 'runtime_error',
646
+ status_code: 0,
647
+ final_url: target.url,
648
+ matched_phrase: error instanceof Error ? error.message : String(error)
649
+ };
650
+ }
646
651
  }
652
+ return { speed_result: speedResult, provider_results: providerResults };
647
653
  }
648
- return {
649
- speed_result: speedResult,
650
- provider_results: providerResults
651
- };
652
- }
653
- finally {
654
- await runtime?.close();
655
- }
654
+ finally {
655
+ await runtime?.close();
656
+ }
657
+ });
656
658
  }
657
659
  async function checkBatchInNode(input, options) {
658
660
  if (input.results.length === 0) {
@@ -0,0 +1,12 @@
1
+ const ISO_ALPHA_2_CODES = new Set(`
2
+ AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
3
+ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
4
+ GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP
5
+ KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT
6
+ MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW
7
+ SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG
8
+ UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
9
+ `.trim().split(/\s+/));
10
+ export function isIsoAlpha2CountryCode(value) {
11
+ return typeof value === 'string' && ISO_ALPHA_2_CODES.has(value.trim().toUpperCase());
12
+ }
@@ -0,0 +1,232 @@
1
+ import { isIP } from 'node:net';
2
+ import { lookup as dnsLookup } from 'node:dns/promises';
3
+ import { isIsoAlpha2CountryCode } from './country-codes.js';
4
+ function countryCode(value) {
5
+ const normalized = typeof value === 'string' ? value.trim().toUpperCase() : '';
6
+ return isIsoAlpha2CountryCode(normalized) ? normalized : null;
7
+ }
8
+ function retryAfterMilliseconds(value, now, maximum) {
9
+ if (!value)
10
+ return 0;
11
+ const seconds = Number(value);
12
+ const delay = Number.isFinite(seconds)
13
+ ? seconds * 1000
14
+ : Date.parse(value) - now;
15
+ return Number.isFinite(delay) && delay > 0 ? Math.min(delay, maximum) : 0;
16
+ }
17
+ function ipv4Number(address) {
18
+ const octets = address.split('.').map(Number);
19
+ if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255))
20
+ return null;
21
+ return (((octets[0] * 256 + octets[1]) * 256 + octets[2]) * 256 + octets[3]) >>> 0;
22
+ }
23
+ function ipv4InCidr(value, base, prefix) {
24
+ const baseValue = ipv4Number(base);
25
+ const size = 2 ** (32 - prefix);
26
+ return Math.floor(value / size) === Math.floor(baseValue / size);
27
+ }
28
+ function isRejectedIpv4(value) {
29
+ return [
30
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
31
+ ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24],
32
+ ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
33
+ ['224.0.0.0', 4], ['240.0.0.0', 4]
34
+ ].some(([base, prefix]) => ipv4InCidr(value, base, prefix));
35
+ }
36
+ function ipv6Number(address) {
37
+ const pieces = address.toLowerCase().split('::');
38
+ if (pieces.length > 2)
39
+ return null;
40
+ const parseSide = (side) => {
41
+ if (!side)
42
+ return [];
43
+ const groups = side.split(':');
44
+ const last = groups.at(-1) ?? '';
45
+ if (last.includes('.')) {
46
+ const mapped = ipv4Number(last);
47
+ if (mapped === null)
48
+ return null;
49
+ groups.splice(-1, 1, ((mapped >>> 16) & 0xffff).toString(16), (mapped & 0xffff).toString(16));
50
+ }
51
+ const parsed = groups.map((group) => /^[0-9a-f]{1,4}$/.test(group) ? Number.parseInt(group, 16) : -1);
52
+ return parsed.some((group) => group < 0) ? null : parsed;
53
+ };
54
+ const left = parseSide(pieces[0]);
55
+ const right = parseSide(pieces[1] ?? '');
56
+ if (!left || !right)
57
+ return null;
58
+ const missing = 8 - left.length - right.length;
59
+ if ((pieces.length === 1 && missing !== 0) || (pieces.length === 2 && missing < 1))
60
+ return null;
61
+ const groups = [...left, ...Array(missing).fill(0), ...right];
62
+ return groups.reduce((value, group) => (value << 16n) | BigInt(group), 0n);
63
+ }
64
+ function canonicalIpv6(value) {
65
+ const groups = Array.from({ length: 8 }, (_, index) => Number((value >> BigInt((7 - index) * 16)) & 0xffffn));
66
+ let bestStart = -1;
67
+ let bestLength = 0;
68
+ for (let index = 0; index < groups.length;) {
69
+ if (groups[index] !== 0) {
70
+ index += 1;
71
+ continue;
72
+ }
73
+ let end = index;
74
+ while (end < groups.length && groups[end] === 0)
75
+ end += 1;
76
+ if (end - index > bestLength) {
77
+ bestStart = index;
78
+ bestLength = end - index;
79
+ }
80
+ index = end;
81
+ }
82
+ const hex = groups.map((group) => group.toString(16));
83
+ if (bestLength < 2)
84
+ return hex.join(':');
85
+ const before = hex.slice(0, bestStart).join(':');
86
+ const after = hex.slice(bestStart + bestLength).join(':');
87
+ return `${before}::${after}`;
88
+ }
89
+ function normalizeGlobalAddress(address) {
90
+ if (isIP(address) === 4) {
91
+ const value = ipv4Number(address);
92
+ return value !== null && !isRejectedIpv4(value) ? address : null;
93
+ }
94
+ const value = ipv6Number(address);
95
+ if (value === null)
96
+ return null;
97
+ if ((value >> 32n) === 0xffffn) {
98
+ const mapped = Number(value & 0xffffffffn) >>> 0;
99
+ if (isRejectedIpv4(mapped))
100
+ return null;
101
+ return `${mapped >>> 24}.${(mapped >>> 16) & 255}.${(mapped >>> 8) & 255}.${mapped & 255}`;
102
+ }
103
+ const globalUnicast = value >= 0x20000000000000000000000000000000n && value <= 0x3fffffffffffffffffffffffffffffffn;
104
+ const ietfProtocolAssignments = value >= 0x20010000000000000000000000000000n && value <= 0x200101ffffffffffffffffffffffffffn;
105
+ const documentation = value >= 0x20010db8000000000000000000000000n && value <= 0x20010db8ffffffffffffffffffffffffn;
106
+ const extendedDocumentation = value >= 0x3fff0000000000000000000000000000n && value <= 0x3fff0fffffffffffffffffffffffffffn;
107
+ return globalUnicast && !ietfProtocolAssignments && !documentation && !extendedDocumentation ? canonicalIpv6(value) : null;
108
+ }
109
+ export function createGeoIpLookup(options = {}) {
110
+ const fetchFn = options.fetch ?? globalThis.fetch;
111
+ const resolve = options.resolve ?? ((hostname) => dnsLookup(hostname, { all: true, verbatim: true }));
112
+ const now = options.now ?? Date.now;
113
+ const sleep = options.sleep ?? ((milliseconds) => new Promise((done) => globalThis.setTimeout(done, milliseconds)));
114
+ const setTimer = options.setTimeout ?? ((callback, milliseconds) => globalThis.setTimeout(callback, milliseconds));
115
+ const clearTimer = options.clearTimeout ?? ((handle) => globalThis.clearTimeout(handle));
116
+ const timeoutMs = options.timeoutMs ?? 3000;
117
+ const successTtlMs = options.successTtlMs ?? 24 * 60 * 60 * 1000;
118
+ const negativeTtlMs = options.negativeTtlMs ?? 60 * 1000;
119
+ const maxRetryAfterMs = options.maxRetryAfterMs ?? 2000;
120
+ const providerConcurrency = Math.max(1, Math.floor(options.providerConcurrency ?? 4));
121
+ const primaryUrl = options.primaryUrl ?? ((ip) => `https://ipwho.is/${encodeURIComponent(ip)}`);
122
+ const fallbackUrl = options.fallbackUrl ?? ((ip) => `https://ipapi.co/${encodeURIComponent(ip)}/json/`);
123
+ const cache = new Map();
124
+ const pending = new Map();
125
+ const providerWaiters = [];
126
+ let activeProviderRequests = 0;
127
+ let providerCooldownUntil = 0;
128
+ async function acquireProviderSlot() {
129
+ if (activeProviderRequests >= providerConcurrency) {
130
+ await new Promise((resolve) => providerWaiters.push(resolve));
131
+ }
132
+ else {
133
+ activeProviderRequests += 1;
134
+ }
135
+ return () => {
136
+ const next = providerWaiters.shift();
137
+ if (next)
138
+ next();
139
+ else
140
+ activeProviderRequests -= 1;
141
+ };
142
+ }
143
+ async function request(url, provider) {
144
+ try {
145
+ const parsed = new URL(url);
146
+ const expectedHost = provider === 'primary' ? 'ipwho.is' : 'ipapi.co';
147
+ if (parsed.protocol !== 'https:' || parsed.hostname !== expectedHost || parsed.port || parsed.username || parsed.password) {
148
+ return { country: null, retryAfterMs: 0 };
149
+ }
150
+ }
151
+ catch {
152
+ return { country: null, retryAfterMs: 0 };
153
+ }
154
+ const release = await acquireProviderSlot();
155
+ const cooldownMs = providerCooldownUntil - now();
156
+ if (cooldownMs > 0)
157
+ await sleep(cooldownMs);
158
+ const controller = new AbortController();
159
+ const timer = setTimer(() => controller.abort(), timeoutMs);
160
+ try {
161
+ const response = await fetchFn(url, { signal: controller.signal, headers: { accept: 'application/json' } });
162
+ const retryAfterMs = response.status === 429
163
+ ? retryAfterMilliseconds(response.headers.get('retry-after'), now(), maxRetryAfterMs)
164
+ : 0;
165
+ if (retryAfterMs > 0)
166
+ providerCooldownUntil = Math.max(providerCooldownUntil, now() + retryAfterMs);
167
+ if (!response.ok)
168
+ return { country: null, retryAfterMs };
169
+ const payload = await response.json();
170
+ if (provider === 'primary' && payload.success !== true)
171
+ return { country: null, retryAfterMs: 0 };
172
+ if (provider === 'fallback' && payload.error === true)
173
+ return { country: null, retryAfterMs: 0 };
174
+ return { country: countryCode(payload.country_code), retryAfterMs: 0 };
175
+ }
176
+ catch {
177
+ return { country: null, retryAfterMs: 0 };
178
+ }
179
+ finally {
180
+ clearTimer(timer);
181
+ release();
182
+ }
183
+ }
184
+ async function lookupIp(ip) {
185
+ const cached = cache.get(ip);
186
+ if (cached && cached.expiresAt > now())
187
+ return cached;
188
+ const existing = pending.get(ip);
189
+ if (existing)
190
+ return existing;
191
+ const operation = (async () => {
192
+ const primary = await request(primaryUrl(ip), 'primary');
193
+ if (primary.country)
194
+ return { country: primary.country, detected: true };
195
+ const fallback = await request(fallbackUrl(ip), 'fallback');
196
+ return fallback.country
197
+ ? { country: fallback.country, detected: true }
198
+ : { country: 'US', detected: false };
199
+ })();
200
+ pending.set(ip, operation);
201
+ try {
202
+ const result = await operation;
203
+ cache.set(ip, { ...result, expiresAt: now() + (result.detected ? successTtlMs : negativeTtlMs) });
204
+ return result;
205
+ }
206
+ finally {
207
+ pending.delete(ip);
208
+ }
209
+ }
210
+ return async (rawAddress) => {
211
+ const address = String(rawAddress ?? '').trim();
212
+ if (!address)
213
+ return 'US';
214
+ try {
215
+ const results = isIP(address) ? [{ address, family: isIP(address) }] : await resolve(address);
216
+ const seen = new Set();
217
+ for (const result of results) {
218
+ const globalAddress = normalizeGlobalAddress(result.address);
219
+ if (!globalAddress || seen.has(globalAddress))
220
+ continue;
221
+ seen.add(globalAddress);
222
+ const resultCountry = await lookupIp(globalAddress);
223
+ if (resultCountry.detected)
224
+ return resultCountry.country;
225
+ }
226
+ return 'US';
227
+ }
228
+ catch {
229
+ return 'US';
230
+ }
231
+ };
232
+ }
@@ -0,0 +1,54 @@
1
+ const DEFAULT_MAX_ATTEMPTS = 2;
2
+ const DEFAULT_RETRY_DELAY_MS = 100;
3
+ export function isTransientNetworkError(error) {
4
+ const message = error instanceof Error ? error.message : String(error);
5
+ const code = typeof error === 'object' && error !== null && 'code' in error
6
+ ? String(error.code ?? '').toUpperCase()
7
+ : '';
8
+ const statusMatch = /(?:unexpected status|failed with status)\s+(\d{3})\b/i.exec(message);
9
+ if (statusMatch) {
10
+ const status = Number(statusMatch[1]);
11
+ return status >= 500 && status <= 599;
12
+ }
13
+ if ([
14
+ 'AUTOVPN_INTERNAL_TIMEOUT',
15
+ 'ECONNRESET',
16
+ 'ETIMEDOUT',
17
+ 'EPIPE',
18
+ 'ECONNREFUSED',
19
+ 'EAI_AGAIN',
20
+ 'ENETUNREACH',
21
+ 'EHOSTUNREACH',
22
+ 'UND_ERR_CONNECT_TIMEOUT',
23
+ 'UND_ERR_HEADERS_TIMEOUT',
24
+ 'UND_ERR_BODY_TIMEOUT'
25
+ ].includes(code)) {
26
+ return true;
27
+ }
28
+ if (error instanceof TypeError && message.trim().toLowerCase() === 'fetch failed') {
29
+ return true;
30
+ }
31
+ return /(?:connection|connect|download|response body|tls handshake).*timed out/i.test(message)
32
+ || /socket hang up|network is unreachable|temporary failure/i.test(message);
33
+ }
34
+ export async function retryTransientNetwork(operation, options = {}) {
35
+ const maxAttempts = Math.max(1, Math.trunc(options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS));
36
+ const delayMs = Math.max(0, Math.trunc(options.delayMs ?? DEFAULT_RETRY_DELAY_MS));
37
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
38
+ let lastError;
39
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
40
+ try {
41
+ return await operation();
42
+ }
43
+ catch (error) {
44
+ lastError = error;
45
+ if (attempt >= maxAttempts || !isTransientNetworkError(error)) {
46
+ throw error;
47
+ }
48
+ if (delayMs > 0) {
49
+ await sleep(delayMs * attempt);
50
+ }
51
+ }
52
+ }
53
+ throw lastError;
54
+ }