blue-js-sdk 2.0.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.
Files changed (215) hide show
  1. package/CHANGELOG.md +446 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/ai-path/ADMIN-ELEVATION.md +116 -0
  5. package/ai-path/AI-MANIFESTO.md +185 -0
  6. package/ai-path/BREAKING.md +74 -0
  7. package/ai-path/CHECKLIST.md +619 -0
  8. package/ai-path/CONNECTION-STEPS.md +724 -0
  9. package/ai-path/DECISION-TREE.md +378 -0
  10. package/ai-path/DEPENDENCIES.md +459 -0
  11. package/ai-path/E2E-FLOW.md +1555 -0
  12. package/ai-path/FAILURES.md +403 -0
  13. package/ai-path/GUIDE.md +1217 -0
  14. package/ai-path/README.md +558 -0
  15. package/ai-path/SPLIT-TUNNEL.md +266 -0
  16. package/ai-path/cli.js +535 -0
  17. package/ai-path/connect.js +884 -0
  18. package/ai-path/discover.js +178 -0
  19. package/ai-path/environment.js +266 -0
  20. package/ai-path/errors.js +86 -0
  21. package/ai-path/examples/autonomous-agent.mjs +220 -0
  22. package/ai-path/examples/multi-region.mjs +174 -0
  23. package/ai-path/examples/one-shot.mjs +31 -0
  24. package/ai-path/index.js +60 -0
  25. package/ai-path/pricing.js +136 -0
  26. package/ai-path/recommend.js +413 -0
  27. package/ai-path/run-admin.vbs +25 -0
  28. package/ai-path/setup.js +291 -0
  29. package/ai-path/wallet.js +137 -0
  30. package/app-helpers.js +363 -0
  31. package/app-settings.js +95 -0
  32. package/app-types.js +267 -0
  33. package/audit.js +847 -0
  34. package/batch.js +293 -0
  35. package/bin/setup.js +376 -0
  36. package/chain/authz.js +109 -0
  37. package/chain/broadcast.js +472 -0
  38. package/chain/client.js +160 -0
  39. package/chain/fee-grants.js +305 -0
  40. package/chain/index.js +891 -0
  41. package/chain/lcd.js +313 -0
  42. package/chain/queries.js +547 -0
  43. package/chain/rpc.js +408 -0
  44. package/chain/wallet.js +141 -0
  45. package/cli/config.js +143 -0
  46. package/cli/index.js +463 -0
  47. package/cli/output.js +182 -0
  48. package/cli.js +491 -0
  49. package/client/index.js +251 -0
  50. package/client.js +271 -0
  51. package/config/index.js +255 -0
  52. package/connection/connect.js +849 -0
  53. package/connection/disconnect.js +180 -0
  54. package/connection/discovery.js +321 -0
  55. package/connection/index.js +76 -0
  56. package/connection/proxy.js +148 -0
  57. package/connection/resilience.js +428 -0
  58. package/connection/security.js +232 -0
  59. package/connection/state.js +369 -0
  60. package/connection/tunnel.js +691 -0
  61. package/consumer.js +132 -0
  62. package/cosmjs-setup.js +1884 -0
  63. package/defaults.js +366 -0
  64. package/disk-cache.js +107 -0
  65. package/dist/client.d.ts +108 -0
  66. package/dist/client.d.ts.map +1 -0
  67. package/dist/client.js +400 -0
  68. package/dist/client.js.map +1 -0
  69. package/dist/index.d.ts +8 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +8 -0
  72. package/dist/index.js.map +1 -0
  73. package/errors/index.js +112 -0
  74. package/errors.js +218 -0
  75. package/examples/README.md +64 -0
  76. package/examples/connect-direct.mjs +106 -0
  77. package/examples/connect-plan.mjs +125 -0
  78. package/examples/error-handling.mjs +109 -0
  79. package/examples/query-nodes.mjs +94 -0
  80. package/examples/wallet-basics.mjs +61 -0
  81. package/generated/amino/amino.ts +9 -0
  82. package/generated/cosmos/base/v1beta1/coin.ts +365 -0
  83. package/generated/cosmos_proto/cosmos.ts +323 -0
  84. package/generated/gogoproto/gogo.ts +9 -0
  85. package/generated/google/protobuf/descriptor.ts +7601 -0
  86. package/generated/google/protobuf/duration.ts +208 -0
  87. package/generated/google/protobuf/timestamp.ts +238 -0
  88. package/generated/sentinel/lease/v1/events.ts +924 -0
  89. package/generated/sentinel/lease/v1/lease.ts +292 -0
  90. package/generated/sentinel/lease/v1/msg.ts +949 -0
  91. package/generated/sentinel/lease/v1/params.ts +164 -0
  92. package/generated/sentinel/node/v3/events.ts +881 -0
  93. package/generated/sentinel/node/v3/msg.ts +1002 -0
  94. package/generated/sentinel/node/v3/node.ts +263 -0
  95. package/generated/sentinel/node/v3/params.ts +183 -0
  96. package/generated/sentinel/plan/v3/events.ts +675 -0
  97. package/generated/sentinel/plan/v3/msg.ts +1191 -0
  98. package/generated/sentinel/plan/v3/plan.ts +283 -0
  99. package/generated/sentinel/provider/v2/events.ts +171 -0
  100. package/generated/sentinel/provider/v2/msg.ts +480 -0
  101. package/generated/sentinel/provider/v2/params.ts +131 -0
  102. package/generated/sentinel/provider/v2/provider.ts +246 -0
  103. package/generated/sentinel/session/v3/events.ts +480 -0
  104. package/generated/sentinel/session/v3/msg.ts +616 -0
  105. package/generated/sentinel/session/v3/params.ts +260 -0
  106. package/generated/sentinel/session/v3/proof.ts +180 -0
  107. package/generated/sentinel/session/v3/session.ts +384 -0
  108. package/generated/sentinel/subscription/v3/events.ts +1181 -0
  109. package/generated/sentinel/subscription/v3/msg.ts +1305 -0
  110. package/generated/sentinel/subscription/v3/params.ts +167 -0
  111. package/generated/sentinel/subscription/v3/subscription.ts +315 -0
  112. package/generated/sentinel/types/v1/bandwidth.ts +124 -0
  113. package/generated/sentinel/types/v1/price.ts +149 -0
  114. package/generated/sentinel/types/v1/renewal.ts +87 -0
  115. package/generated/sentinel/types/v1/status.ts +54 -0
  116. package/generated/typeRegistry.ts +27 -0
  117. package/index.js +486 -0
  118. package/node-connect.js +3015 -0
  119. package/operator.js +134 -0
  120. package/package.json +113 -0
  121. package/plan-operations.js +199 -0
  122. package/preflight.js +352 -0
  123. package/pricing/index.js +262 -0
  124. package/proto/amino/amino.proto +84 -0
  125. package/proto/cosmos/base/v1beta1/coin.proto +61 -0
  126. package/proto/cosmos_proto/cosmos.proto +112 -0
  127. package/proto/gogoproto/gogo.proto +145 -0
  128. package/proto/google/api/annotations.proto +31 -0
  129. package/proto/google/api/http.proto +370 -0
  130. package/proto/google/protobuf/any.proto +106 -0
  131. package/proto/google/protobuf/duration.proto +115 -0
  132. package/proto/google/protobuf/timestamp.proto +145 -0
  133. package/proto/sentinel/lease/v1/events.proto +52 -0
  134. package/proto/sentinel/lease/v1/genesis.proto +15 -0
  135. package/proto/sentinel/lease/v1/lease.proto +25 -0
  136. package/proto/sentinel/lease/v1/msg.proto +62 -0
  137. package/proto/sentinel/lease/v1/params.proto +17 -0
  138. package/proto/sentinel/node/v3/events.proto +50 -0
  139. package/proto/sentinel/node/v3/genesis.proto +15 -0
  140. package/proto/sentinel/node/v3/msg.proto +63 -0
  141. package/proto/sentinel/node/v3/node.proto +27 -0
  142. package/proto/sentinel/node/v3/params.proto +21 -0
  143. package/proto/sentinel/node/v3/querier.proto +63 -0
  144. package/proto/sentinel/plan/v3/events.proto +41 -0
  145. package/proto/sentinel/plan/v3/genesis.proto +21 -0
  146. package/proto/sentinel/plan/v3/msg.proto +83 -0
  147. package/proto/sentinel/plan/v3/plan.proto +32 -0
  148. package/proto/sentinel/plan/v3/querier.proto +53 -0
  149. package/proto/sentinel/provider/v2/events.proto +16 -0
  150. package/proto/sentinel/provider/v2/genesis.proto +15 -0
  151. package/proto/sentinel/provider/v2/msg.proto +35 -0
  152. package/proto/sentinel/provider/v2/params.proto +17 -0
  153. package/proto/sentinel/provider/v2/provider.proto +24 -0
  154. package/proto/sentinel/provider/v3/genesis.proto +15 -0
  155. package/proto/sentinel/provider/v3/params.proto +13 -0
  156. package/proto/sentinel/session/v3/events.proto +30 -0
  157. package/proto/sentinel/session/v3/genesis.proto +15 -0
  158. package/proto/sentinel/session/v3/msg.proto +50 -0
  159. package/proto/sentinel/session/v3/params.proto +25 -0
  160. package/proto/sentinel/session/v3/proof.proto +25 -0
  161. package/proto/sentinel/session/v3/querier.proto +100 -0
  162. package/proto/sentinel/session/v3/session.proto +50 -0
  163. package/proto/sentinel/subscription/v2/allocation.proto +21 -0
  164. package/proto/sentinel/subscription/v2/payout.proto +22 -0
  165. package/proto/sentinel/subscription/v3/events.proto +65 -0
  166. package/proto/sentinel/subscription/v3/genesis.proto +17 -0
  167. package/proto/sentinel/subscription/v3/msg.proto +83 -0
  168. package/proto/sentinel/subscription/v3/params.proto +21 -0
  169. package/proto/sentinel/subscription/v3/subscription.proto +33 -0
  170. package/proto/sentinel/types/v1/bandwidth.proto +19 -0
  171. package/proto/sentinel/types/v1/price.proto +21 -0
  172. package/proto/sentinel/types/v1/renewal.proto +21 -0
  173. package/proto/sentinel/types/v1/status.proto +16 -0
  174. package/protocol/encoding.js +341 -0
  175. package/protocol/events.js +361 -0
  176. package/protocol/handshake.js +297 -0
  177. package/protocol/index.js +15 -0
  178. package/protocol/messages.js +346 -0
  179. package/protocol/plans.js +199 -0
  180. package/protocol/v2ray.js +268 -0
  181. package/protocol/v3.js +723 -0
  182. package/protocol/wireguard.js +125 -0
  183. package/security/index.js +132 -0
  184. package/session-manager.js +329 -0
  185. package/session-tracker.js +80 -0
  186. package/setup.js +376 -0
  187. package/speedtest/index.js +528 -0
  188. package/speedtest.js +567 -0
  189. package/src/client.ts +502 -0
  190. package/src/index.ts +20 -0
  191. package/state/index.js +347 -0
  192. package/state.js +516 -0
  193. package/test-all-chain-ops.js +493 -0
  194. package/test-all-logic.js +199 -0
  195. package/test-all-msg-types.js +292 -0
  196. package/test-every-connection.js +208 -0
  197. package/test-feegrant-connect.js +98 -0
  198. package/test-logic.js +148 -0
  199. package/test-mainnet.js +176 -0
  200. package/test-plan-lifecycle.js +335 -0
  201. package/tls-trust.js +132 -0
  202. package/tsconfig.build.json +20 -0
  203. package/tsconfig.json +34 -0
  204. package/types/chain.d.ts +746 -0
  205. package/types/connection.d.ts +425 -0
  206. package/types/errors.d.ts +174 -0
  207. package/types/index.d.ts +1380 -0
  208. package/types/nodes.d.ts +187 -0
  209. package/types/pricing.d.ts +156 -0
  210. package/types/protocol.d.ts +332 -0
  211. package/types/session.d.ts +236 -0
  212. package/types/settings.d.ts +192 -0
  213. package/v3protocol.js +1053 -0
  214. package/wallet/index.js +153 -0
  215. package/wireguard.js +307 -0
@@ -0,0 +1,528 @@
1
+ /**
2
+ * Speed test using Cloudflare's public speed test CDN.
3
+ * No token or auth required. Accurate and reliable.
4
+ *
5
+ * WireGuard: direct (all traffic routes through tunnel)
6
+ * V2Ray: via SOCKS5 proxy on localhost:PORT
7
+ *
8
+ * ─── Methodology ───────────────────────────────────────────────────
9
+ *
10
+ * Multi-request approach:
11
+ * Downloads N sequential chunks, each over a FRESH TCP+TLS connection.
12
+ * VPN overhead (extra RTT per connection) compounds across chunks,
13
+ * creating a genuine, measurable speed gap vs direct connection.
14
+ * This is both FAIR (same test for both) and REALISTIC (real browsing
15
+ * makes many small requests, not one big stream).
16
+ *
17
+ * Adaptive phases:
18
+ * Phase 1 (probe): 1 × 1MB download (~1s at 10 Mbps)
19
+ * Phase 2 (full): 5 × 1MB sequential downloads (if probe > 3 Mbps)
20
+ * If probe < 3 Mbps, report probe result directly (saves time on slow nodes)
21
+ *
22
+ * Calculation:
23
+ * mbps = (totalBytes × 8) / totalSeconds / 1,000,000
24
+ * Result rounded to 2 decimal places via bytesToMbps(bytes, seconds, 2)
25
+ *
26
+ * Parameters (hardcoded):
27
+ * CHUNK_BYTES = 1,048,576 (1 MB per request)
28
+ * CHUNK_COUNT = 5 (sequential downloads in phase 2)
29
+ * PROBE_CUTOFF = 3 Mbps (below this, skip phase 2)
30
+ * CF_HOST = speed.cloudflare.com (Cloudflare CDN, no auth)
31
+ *
32
+ * Fallback chain (if Cloudflare fails):
33
+ * 1. Cloudflare CDN via pre-resolved IP (avoids DNS issues behind VPN)
34
+ * 2. Fallback targets: Ubuntu releases, Hetzner mirror, Debian CD
35
+ * 3. google.com single-page download (last resort, low accuracy)
36
+ *
37
+ * DNS handling:
38
+ * CF hostname is pre-resolved once via resolveCfIp() and cached.
39
+ * This avoids DNS failures when testing through WireGuard tunnels
40
+ * (which may not have DNS configured yet).
41
+ *
42
+ * SOCKS5 notes:
43
+ * V2Ray tests MUST use axios with SocksProxyAgent — native fetch
44
+ * silently ignores SOCKS proxy agents. Each chunk gets a fresh agent
45
+ * to avoid connection reuse (which would hide per-request overhead).
46
+ */
47
+
48
+ import axios from 'axios';
49
+ import https from 'https';
50
+ import dns from 'dns';
51
+ import { SocksProxyAgent } from 'socks-proxy-agent';
52
+
53
+ // axios adapter set in defaults.js — prevents undici "fetch failed" on Node.js v18+.
54
+ import { sleep, bytesToMbps } from '../config/index.js';
55
+
56
+ // Cloudflare speed test CDN — always up, no auth, geographically distributed
57
+ const CF_HOST = 'speed.cloudflare.com';
58
+ const CF_DOWN = `https://${CF_HOST}/__down`;
59
+
60
+ // Multi-request test parameters
61
+ const CHUNK_BYTES = 1 * 1024 * 1024; // 1MB per chunk
62
+ const CHUNK_COUNT = 5; // 5 sequential requests = 5MB total
63
+ const PROBE_BYTES = 1 * 1024 * 1024; // 1MB probe
64
+
65
+ /** Exported speed test configuration constants. Read-only reference for consumers. */
66
+ export const SPEEDTEST_DEFAULTS = Object.freeze({
67
+ chunkBytes: CHUNK_BYTES,
68
+ chunkCount: CHUNK_COUNT,
69
+ probeBytes: PROBE_BYTES,
70
+ probeThresholdMbps: 3,
71
+ primaryHost: CF_HOST,
72
+ dnsCacheTtl: 5 * 60_000,
73
+ fallbackHosts: [
74
+ { host: 'proof.ovh.net', path: '/files/1Mb.dat', size: 1_000_000 },
75
+ { host: 'speedtest.tele2.net', path: '/1MB.zip', size: 1_000_000 },
76
+ ],
77
+ });
78
+
79
+ // Fallback download targets when Cloudflare is unreachable through a tunnel
80
+ const FALLBACK_URLS = SPEEDTEST_DEFAULTS.fallbackHosts;
81
+
82
+ // Cache resolved IP with TTL (survives WireGuard DNS breakage, refreshes on stale)
83
+ let cachedCfIp = null;
84
+ let cachedCfTime = 0;
85
+ let cachedFallbackIps = {};
86
+ const DNS_CACHE_TTL = SPEEDTEST_DEFAULTS.dnsCacheTtl;
87
+
88
+ /** Flush cached DNS resolutions. Call when switching VPN connections. */
89
+ export function flushSpeedTestDnsCache() {
90
+ cachedCfIp = null;
91
+ cachedCfTime = 0;
92
+ cachedFallbackIps = {};
93
+ }
94
+
95
+ async function resolveCfHost() {
96
+ if (cachedCfIp && Date.now() - cachedCfTime < DNS_CACHE_TTL) return cachedCfIp;
97
+
98
+ // Method 1: Explicit resolver to 1.1.1.1 (most reliable — bypasses broken system DNS)
99
+ try {
100
+ const resolver = new dns.Resolver();
101
+ resolver.setServers(['1.1.1.1', '8.8.8.8']);
102
+ const addrs = await new Promise((resolve, reject) => {
103
+ resolver.resolve4(CF_HOST, (err, addresses) => err ? reject(err) : resolve(addresses));
104
+ });
105
+ if (addrs.length > 0) { cachedCfIp = addrs[0]; cachedCfTime = Date.now(); return cachedCfIp; }
106
+ } catch { }
107
+
108
+ // Method 2: Default resolve4 (uses c-ares — fails on Windows 11 DoH setups)
109
+ try {
110
+ const addrs = await dns.promises.resolve4(CF_HOST);
111
+ if (addrs.length > 0) { cachedCfIp = addrs[0]; cachedCfTime = Date.now(); return cachedCfIp; }
112
+ } catch { }
113
+
114
+ // Method 3: OS resolver (getaddrinfo — always works but may return CDN-specific IP)
115
+ try {
116
+ const { address } = await dns.promises.lookup(CF_HOST);
117
+ cachedCfIp = address;
118
+ cachedCfTime = Date.now();
119
+ return cachedCfIp;
120
+ } catch { }
121
+
122
+ return null;
123
+ }
124
+
125
+ /** Pre-resolve fallback hosts so they work behind WireGuard tunnels too. */
126
+ async function resolveFallbackHosts() {
127
+ for (const fb of FALLBACK_URLS) {
128
+ if (cachedFallbackIps[fb.host]) continue;
129
+ try {
130
+ const { address } = await dns.promises.lookup(fb.host);
131
+ cachedFallbackIps[fb.host] = address;
132
+ } catch {} // DNS resolution may fail — fallback will use hostname directly
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Download limitBytes from url with a FRESH TCP+TLS connection.
138
+ * Uses https.get (NOT fetch) because native fetch silently ignores the agent option.
139
+ * agent: false ensures no keep-alive — every call does a full TCP+TLS handshake.
140
+ * timeoutMs defaults to 30s but can be increased for tunnel retries.
141
+ */
142
+ function freshDownload(url, limitBytes, agentOpts, timeoutMs = 30000) {
143
+ return new Promise((resolve, reject) => {
144
+ let downloaded = 0;
145
+ const start = Date.now();
146
+ const parsed = new URL(url);
147
+ let finished = false;
148
+
149
+ function done(err) {
150
+ if (finished) return;
151
+ finished = true;
152
+ const elapsed = (Date.now() - start) / 1000;
153
+ if (err && downloaded === 0) { reject(err); return; }
154
+ if (elapsed <= 0 || downloaded === 0) { reject(new Error('No data received')); return; }
155
+ resolve({ bytes: downloaded, seconds: elapsed });
156
+ }
157
+
158
+ const options = {
159
+ hostname: parsed.hostname,
160
+ path: parsed.pathname + parsed.search,
161
+ headers: {},
162
+ rejectUnauthorized: false,
163
+ agent: false, // CRITICAL: fresh TCP+TLS connection every time (no keep-alive)
164
+ };
165
+
166
+ // IP-based URL: set Host header and TLS SNI so server accepts it
167
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(parsed.hostname)) {
168
+ const hostName = agentOpts?.fallbackHost || CF_HOST;
169
+ options.headers['Host'] = hostName;
170
+ options.servername = hostName;
171
+ }
172
+
173
+ // Allow custom agent (overrides agent: false) for specific cases
174
+ if (agentOpts?.httpsAgent) {
175
+ options.agent = agentOpts.httpsAgent;
176
+ }
177
+
178
+ const req = https.get(options, (res) => {
179
+ if (res.statusCode !== 200) {
180
+ req.destroy();
181
+ done(new Error(`HTTP ${res.statusCode}`));
182
+ return;
183
+ }
184
+ res.on('data', (chunk) => {
185
+ downloaded += chunk.length;
186
+ if (downloaded >= limitBytes) {
187
+ res.destroy();
188
+ done();
189
+ }
190
+ });
191
+ res.on('end', () => done());
192
+ res.on('error', (err) => done(err));
193
+ });
194
+
195
+ req.on('error', (err) => done(err));
196
+ req.setTimeout(timeoutMs, () => {
197
+ req.destroy();
198
+ done(new Error('timeout'));
199
+ });
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Last-resort single-stream download with long timeout (60s).
205
+ * Used when the multi-request test fails through a tunnel.
206
+ * Downloads a smaller amount (2MB) with a keep-alive agent for reliability.
207
+ * Returns low but valid speed rather than failing the node entirely.
208
+ */
209
+ async function rescueDownload() {
210
+ const RESCUE_BYTES = 2 * 1024 * 1024;
211
+ const rescueAgent = new https.Agent({ rejectUnauthorized: false, servername: CF_HOST, keepAlive: true });
212
+
213
+ // Try: IP with agent, hostname with agent, fallback hosts
214
+ const urls = [];
215
+ if (cachedCfIp) urls.push(`https://${cachedCfIp}/__down?bytes=${RESCUE_BYTES}`);
216
+ urls.push(`${CF_DOWN}?bytes=${RESCUE_BYTES}`);
217
+
218
+ for (const url of urls) {
219
+ try {
220
+ const r = await freshDownload(url, RESCUE_BYTES, { httpsAgent: rescueAgent }, 60000);
221
+ const mbps = bytesToMbps(r.bytes, r.seconds);
222
+ rescueAgent.destroy();
223
+ return { mbps: parseFloat(mbps.toFixed(2)), chunks: 1, adaptive: 'rescue' };
224
+ } catch {}
225
+ }
226
+
227
+ // Try fallback URLs with long timeout
228
+ for (const fb of FALLBACK_URLS) {
229
+ const ip = cachedFallbackIps[fb.host];
230
+ const targets = [];
231
+ if (ip) targets.push({ url: `https://${ip}${fb.path}`, opts: { httpsAgent: rescueAgent, fallbackHost: fb.host } });
232
+ targets.push({ url: `https://${fb.host}${fb.path}`, opts: { httpsAgent: rescueAgent } });
233
+
234
+ for (const t of targets) {
235
+ try {
236
+ const r = await freshDownload(t.url, fb.size, t.opts, 60000);
237
+ const mbps = bytesToMbps(r.bytes, r.seconds);
238
+ rescueAgent.destroy();
239
+ return { mbps: parseFloat(mbps.toFixed(2)), chunks: 1, adaptive: 'rescue-fallback', fallbackHost: fb.host };
240
+ } catch {}
241
+ }
242
+ }
243
+
244
+ rescueAgent.destroy();
245
+ return null;
246
+ }
247
+
248
+ /**
249
+ * Multi-request speed test: download N chunks sequentially, each with fresh TCP+TLS.
250
+ * Total elapsed time includes all connection overhead (handshakes compound).
251
+ * VPN latency shows up as genuinely lower effective throughput.
252
+ */
253
+ async function multiRequestMeasure(baseUrl, chunkBytes, chunkCount, agentOpts) {
254
+ let totalBytes = 0;
255
+ let successCount = 0;
256
+ const overallStart = Date.now();
257
+
258
+ for (let i = 0; i < chunkCount; i++) {
259
+ try {
260
+ const r = await freshDownload(baseUrl, chunkBytes, agentOpts);
261
+ totalBytes += r.bytes;
262
+ successCount++;
263
+ } catch {
264
+ // Allow partial success — report based on successful chunks
265
+ if (successCount === 0 && i === chunkCount - 1) {
266
+ throw new Error('All chunks failed');
267
+ }
268
+ }
269
+ }
270
+
271
+ if (successCount === 0) throw new Error('All chunks failed');
272
+
273
+ const totalElapsed = (Date.now() - overallStart) / 1000;
274
+ const mbps = bytesToMbps(totalBytes, totalElapsed, 2);
275
+ return { mbps, chunks: successCount, totalBytes, seconds: totalElapsed };
276
+ }
277
+
278
+ /**
279
+ * Fallback speed measurement — download a known-size file via HTTPS.
280
+ * Used when Cloudflare is unreachable through a WireGuard tunnel.
281
+ */
282
+ async function fallbackMeasure(agentOpts) {
283
+ for (const fb of FALLBACK_URLS) {
284
+ const ip = cachedFallbackIps[fb.host];
285
+ if (!ip) continue;
286
+ try {
287
+ const result = await freshDownload(
288
+ `https://${ip}${fb.path}`,
289
+ fb.size,
290
+ { ...agentOpts, fallbackHost: fb.host }
291
+ );
292
+ return { mbps: bytesToMbps(result.bytes, result.seconds, 2), chunks: 1, adaptive: 'fallback', fallbackHost: fb.host };
293
+ } catch {}
294
+ // Also try hostname directly
295
+ try {
296
+ const result = await freshDownload(
297
+ `https://${fb.host}${fb.path}`,
298
+ fb.size,
299
+ agentOpts
300
+ );
301
+ return { mbps: bytesToMbps(result.bytes, result.seconds, 2), chunks: 1, adaptive: 'fallback', fallbackHost: fb.host };
302
+ } catch {}
303
+ }
304
+ return null;
305
+ }
306
+
307
+
308
+ /**
309
+ * Direct speedtest — used for baseline and WireGuard tunnel testing.
310
+ * All traffic goes through the active network interface (WireGuard tunnel when up).
311
+ * Pre-resolves CF hostname to avoid DNS failures behind WireGuard tunnels.
312
+ *
313
+ * Multi-request approach: 5 × 1MB sequential downloads, each with fresh TCP+TLS.
314
+ * VPN overhead (extra handshake latency per request) creates genuine speed gap.
315
+ */
316
+ export async function speedtestDirect() {
317
+ await resolveCfHost();
318
+ await resolveFallbackHosts();
319
+
320
+ // Build URL — try IP first (avoids DNS failures behind WireGuard tunnels)
321
+ function cfUrl(bytes) {
322
+ return cachedCfIp
323
+ ? `https://${cachedCfIp}/__down?bytes=${bytes}`
324
+ : `${CF_DOWN}?bytes=${bytes}`;
325
+ }
326
+ function cfUrlHostname(bytes) {
327
+ return `${CF_DOWN}?bytes=${bytes}`;
328
+ }
329
+
330
+ // Phase 1: Quick 1MB single probe
331
+ let probe;
332
+ try {
333
+ probe = await freshDownload(cfUrl(PROBE_BYTES), PROBE_BYTES, {});
334
+ } catch {
335
+ // IP failed, try hostname
336
+ try {
337
+ probe = await freshDownload(cfUrlHostname(PROBE_BYTES), PROBE_BYTES, {});
338
+ } catch {
339
+ // Cloudflare unreachable with fresh connections — try fallback targets
340
+ const fb = await fallbackMeasure({});
341
+ if (fb) return fb;
342
+ // Last resort: rescue download with keep-alive agent + 60s timeout
343
+ const rescue = await rescueDownload();
344
+ if (rescue) return rescue;
345
+ throw new Error('Speed test failed (CF and all fallbacks unreachable)');
346
+ }
347
+ }
348
+
349
+ const probeMbps = bytesToMbps(probe.bytes, probe.seconds, 2);
350
+
351
+ // If probe speed is low (< 3 Mbps), don't waste time on full test
352
+ if (probeMbps < 3) {
353
+ return { mbps: probeMbps, chunks: 1, adaptive: 'probe-only' };
354
+ }
355
+
356
+ // Phase 2: Multi-request test — 5 × 1MB sequential downloads
357
+ const url = cfUrl(CHUNK_BYTES);
358
+ try {
359
+ const full = await multiRequestMeasure(url, CHUNK_BYTES, CHUNK_COUNT, {});
360
+ return { mbps: full.mbps, chunks: full.chunks, adaptive: 'multi-request' };
361
+ } catch {
362
+ // Try hostname fallback
363
+ try {
364
+ const full = await multiRequestMeasure(cfUrlHostname(CHUNK_BYTES), CHUNK_BYTES, CHUNK_COUNT, {});
365
+ return { mbps: full.mbps, chunks: full.chunks, adaptive: 'multi-request' };
366
+ } catch {
367
+ // Full test failed but probe worked — return probe result
368
+ return { mbps: probeMbps, chunks: 1, adaptive: 'probe-fallback' };
369
+ }
370
+ }
371
+ }
372
+
373
+ /**
374
+ * SOCKS5 speedtest — used for V2Ray tunnel testing.
375
+ * Routes through the SOCKS5 proxy at localhost:proxyPort.
376
+ * Uses axios (not native fetch) because undici ignores the agent option for SOCKS5.
377
+ *
378
+ * IMPORTANT: Creates a fresh SocksProxyAgent per request to avoid connection
379
+ * reuse issues with V2Ray's SOCKS5 handler. Uses arraybuffer mode (not stream)
380
+ * because stream mode causes TLS handshake failures with some SOCKS5 proxies.
381
+ *
382
+ * Multi-request: 5 × 1MB sequential downloads, each with fresh SOCKS5+TCP+TLS.
383
+ */
384
+ export async function speedtestViaSocks5(testMb = 5, proxyPort = 1080, socksAuth = null) {
385
+ // Fresh agent per request — V2Ray SOCKS5 can fail with connection reuse
386
+ function makeAgent() {
387
+ const authStr = socksAuth ? `${socksAuth.user}:${socksAuth.pass}@` : '';
388
+ return new SocksProxyAgent(`socks5://${authStr}127.0.0.1:${proxyPort}`);
389
+ }
390
+
391
+ async function measure(url, bytes, timeoutMs = 30_000) {
392
+ const agent = makeAgent();
393
+ try {
394
+ const start = Date.now();
395
+ const res = await axios.get(url, {
396
+ responseType: 'arraybuffer',
397
+ timeout: timeoutMs,
398
+ httpAgent: agent,
399
+ httpsAgent: agent,
400
+ });
401
+ const downloaded = res.data.byteLength;
402
+ const elapsed = (Date.now() - start) / 1000;
403
+ if (elapsed <= 0 || downloaded === 0) throw new Error('No data received');
404
+ return { bytes: downloaded, seconds: elapsed };
405
+ } finally {
406
+ agent.destroy();
407
+ }
408
+ }
409
+
410
+ // Phase 0: Quick connectivity check — verify the SOCKS5 tunnel can reach the internet at all.
411
+ // Without this, nodes with working tunnels get marked as failures just because speedtest
412
+ // targets (CF, OVH, Tele2) are blocked by the node's ISP/firewall.
413
+ //
414
+ // Retry once: V2Ray SOCKS5 binding is async and variable. Even after waiting for the port
415
+ // to accept TCP connections, the proxy pipeline may not be fully ready. A single retry
416
+ // after a 3s pause catches slow-starting nodes that would otherwise be false failures.
417
+ const CONNECTIVITY_TARGETS = [
418
+ 'https://www.google.com',
419
+ 'https://www.cloudflare.com',
420
+ 'https://one.one.one.one',
421
+ ];
422
+ let tunnelConnected = false;
423
+ for (let attempt = 0; attempt < 2 && !tunnelConnected; attempt++) {
424
+ if (attempt > 0) await new Promise(r => setTimeout(r, 3000));
425
+ for (const target of CONNECTIVITY_TARGETS) {
426
+ const agent = makeAgent();
427
+ try {
428
+ await axios.get(target, { timeout: 10_000, httpAgent: agent, httpsAgent: agent, maxRedirects: 2, validateStatus: () => true });
429
+ tunnelConnected = true;
430
+ break;
431
+ } catch {} finally { agent.destroy(); }
432
+ }
433
+ }
434
+ if (!tunnelConnected) {
435
+ throw new Error('SOCKS5 tunnel has no internet connectivity (google/cloudflare/1.1.1.1 all unreachable after 2 attempts)');
436
+ }
437
+
438
+ // Phase 1: 1MB single probe — try CF first, then fallback targets, then rescue with 60s timeout
439
+ let probe;
440
+ let probeSource = 'cloudflare';
441
+ try {
442
+ probe = await measure(`${CF_DOWN}?bytes=${PROBE_BYTES}`, PROBE_BYTES);
443
+ } catch {
444
+ // CF download failed via SOCKS5 — try fallback download targets
445
+ let fallbackOk = false;
446
+ for (const fb of FALLBACK_URLS) {
447
+ try {
448
+ probe = await measure(`https://${fb.host}${fb.path}`, fb.size);
449
+ probeSource = fb.host;
450
+ fallbackOk = true;
451
+ break;
452
+ } catch {}
453
+ }
454
+ if (!fallbackOk) {
455
+ // Last resort: retry CF with 60s timeout (slow tunnels need more time)
456
+ try {
457
+ probe = await measure(`${CF_DOWN}?bytes=${PROBE_BYTES}`, PROBE_BYTES, 60_000);
458
+ } catch {
459
+ // Tunnel IS connected (phase 0 passed) but all download targets are blocked.
460
+ // Use a timed GET of a known page as rough speed estimate instead of giving up.
461
+ const agent = makeAgent();
462
+ try {
463
+ const start = Date.now();
464
+ const res = await axios.get('https://www.google.com', {
465
+ responseType: 'arraybuffer', timeout: 15_000,
466
+ httpAgent: agent, httpsAgent: agent,
467
+ });
468
+ const bytes = res.data.byteLength;
469
+ const elapsed = (Date.now() - start) / 1000;
470
+ if (bytes > 0 && elapsed > 0) {
471
+ return { mbps: Math.max(bytesToMbps(bytes, elapsed, 2), 0.1), chunks: 1, adaptive: 'google-fallback' };
472
+ }
473
+ } catch {} finally { agent.destroy(); }
474
+ throw new Error('SOCKS5 speed test failed (CF and all fallbacks unreachable)');
475
+ }
476
+ }
477
+ }
478
+
479
+ const probeMbps = bytesToMbps(probe.bytes, probe.seconds, 2);
480
+
481
+ if (probeMbps < 3) {
482
+ return { mbps: probeMbps, chunks: 1, adaptive: 'probe-only' };
483
+ }
484
+
485
+ // Phase 2: Multi-request — 5 × 1MB sequential downloads, each with fresh SOCKS5 agent
486
+ let totalBytes = 0;
487
+ let successCount = 0;
488
+ const overallStart = Date.now();
489
+
490
+ for (let i = 0; i < CHUNK_COUNT; i++) {
491
+ try {
492
+ const r = await measure(`${CF_DOWN}?bytes=${CHUNK_BYTES}`, CHUNK_BYTES);
493
+ totalBytes += r.bytes;
494
+ successCount++;
495
+ } catch {
496
+ if (successCount === 0 && i === CHUNK_COUNT - 1) {
497
+ // All failed — return probe
498
+ return { mbps: probeMbps, chunks: 1, adaptive: 'probe-fallback' };
499
+ }
500
+ }
501
+ }
502
+
503
+ if (successCount === 0) {
504
+ return { mbps: probeMbps, chunks: 1, adaptive: 'probe-fallback' };
505
+ }
506
+
507
+ const totalElapsed = (Date.now() - overallStart) / 1000;
508
+ return { mbps: bytesToMbps(totalBytes, totalElapsed, 2), chunks: successCount, adaptive: 'multi-request' };
509
+ }
510
+
511
+ /** Pre-resolve CF hostname so WireGuard DNS issues don't affect speedtests. Call once at startup. */
512
+ export { resolveCfHost };
513
+
514
+ /**
515
+ * Resolve all speedtest target IPs (Cloudflare + fallbacks).
516
+ * Used for WireGuard split tunneling — only these IPs get routed through the tunnel.
517
+ * MUST be called BEFORE installing the tunnel (DNS won't work through a dead tunnel).
518
+ */
519
+ export async function resolveSpeedtestIPs() {
520
+ await resolveCfHost();
521
+ await resolveFallbackHosts();
522
+ const ips = [];
523
+ if (cachedCfIp) ips.push(cachedCfIp);
524
+ for (const ip of Object.values(cachedFallbackIps)) {
525
+ if (ip) ips.push(ip);
526
+ }
527
+ return ips;
528
+ }