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
package/speedtest.js ADDED
@@ -0,0 +1,567 @@
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 './defaults.js';
55
+ import { TunnelError, ErrorCodes } from './errors.js';
56
+
57
+ // Cloudflare speed test CDN — always up, no auth, geographically distributed
58
+ const CF_HOST = 'speed.cloudflare.com';
59
+ const CF_DOWN = `https://${CF_HOST}/__down`;
60
+
61
+ // Multi-request test parameters
62
+ const CHUNK_BYTES = 1 * 1024 * 1024; // 1MB per chunk
63
+ const CHUNK_COUNT = 5; // 5 sequential requests = 5MB total
64
+ const PROBE_BYTES = 1 * 1024 * 1024; // 1MB probe
65
+
66
+ /** Exported speed test configuration constants. Read-only reference for consumers. */
67
+ export const SPEEDTEST_DEFAULTS = Object.freeze({
68
+ chunkBytes: CHUNK_BYTES,
69
+ chunkCount: CHUNK_COUNT,
70
+ probeBytes: PROBE_BYTES,
71
+ probeThresholdMbps: 3,
72
+ primaryHost: CF_HOST,
73
+ dnsCacheTtl: 5 * 60_000,
74
+ fallbackHosts: [
75
+ { host: 'proof.ovh.net', path: '/files/1Mb.dat', size: 1_000_000 },
76
+ { host: 'speedtest.tele2.net', path: '/1MB.zip', size: 1_000_000 },
77
+ ],
78
+ });
79
+
80
+ // Fallback download targets when Cloudflare is unreachable through a tunnel
81
+ const FALLBACK_URLS = SPEEDTEST_DEFAULTS.fallbackHosts;
82
+
83
+ // Cache resolved IP with TTL (survives WireGuard DNS breakage, refreshes on stale)
84
+ let cachedCfIp = null;
85
+ let cachedCfTime = 0;
86
+ let cachedFallbackIps = {};
87
+ const DNS_CACHE_TTL = SPEEDTEST_DEFAULTS.dnsCacheTtl;
88
+
89
+ /** Flush cached DNS resolutions. Call when switching VPN connections. */
90
+ export function flushSpeedTestDnsCache() {
91
+ cachedCfIp = null;
92
+ cachedCfTime = 0;
93
+ cachedFallbackIps = {};
94
+ }
95
+
96
+ async function resolveCfHost() {
97
+ if (cachedCfIp && Date.now() - cachedCfTime < DNS_CACHE_TTL) return cachedCfIp;
98
+
99
+ // Method 1: Explicit resolver to 1.1.1.1 (most reliable — bypasses broken system DNS)
100
+ try {
101
+ const resolver = new dns.Resolver();
102
+ resolver.setServers(['1.1.1.1', '8.8.8.8']);
103
+ const addrs = await new Promise((resolve, reject) => {
104
+ resolver.resolve4(CF_HOST, (err, addresses) => err ? reject(err) : resolve(addresses));
105
+ });
106
+ if (addrs.length > 0) { cachedCfIp = addrs[0]; cachedCfTime = Date.now(); return cachedCfIp; }
107
+ } catch { }
108
+
109
+ // Method 2: Default resolve4 (uses c-ares — fails on Windows 11 DoH setups)
110
+ try {
111
+ const addrs = await dns.promises.resolve4(CF_HOST);
112
+ if (addrs.length > 0) { cachedCfIp = addrs[0]; cachedCfTime = Date.now(); return cachedCfIp; }
113
+ } catch { }
114
+
115
+ // Method 3: OS resolver (getaddrinfo — always works but may return CDN-specific IP)
116
+ try {
117
+ const { address } = await dns.promises.lookup(CF_HOST);
118
+ cachedCfIp = address;
119
+ cachedCfTime = Date.now();
120
+ return cachedCfIp;
121
+ } catch { }
122
+
123
+ return null;
124
+ }
125
+
126
+ /** Pre-resolve fallback hosts so they work behind WireGuard tunnels too. */
127
+ async function resolveFallbackHosts() {
128
+ for (const fb of FALLBACK_URLS) {
129
+ if (cachedFallbackIps[fb.host]) continue;
130
+ try {
131
+ const { address } = await dns.promises.lookup(fb.host);
132
+ cachedFallbackIps[fb.host] = address;
133
+ } catch {} // DNS resolution may fail — fallback will use hostname directly
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Download limitBytes from url with a FRESH TCP+TLS connection.
139
+ * Uses https.get (NOT fetch) because native fetch silently ignores the agent option.
140
+ * agent: false ensures no keep-alive — every call does a full TCP+TLS handshake.
141
+ * timeoutMs defaults to 30s but can be increased for tunnel retries.
142
+ */
143
+ function freshDownload(url, limitBytes, agentOpts, timeoutMs = 30000) {
144
+ return new Promise((resolve, reject) => {
145
+ let downloaded = 0;
146
+ const start = Date.now();
147
+ const parsed = new URL(url);
148
+ let finished = false;
149
+
150
+ function done(err) {
151
+ if (finished) return;
152
+ finished = true;
153
+ const elapsed = (Date.now() - start) / 1000;
154
+ if (err && downloaded === 0) { reject(err); return; }
155
+ if (elapsed <= 0 || downloaded === 0) { reject(new Error('No data received')); return; }
156
+ resolve({ bytes: downloaded, seconds: elapsed });
157
+ }
158
+
159
+ const options = {
160
+ hostname: parsed.hostname,
161
+ path: parsed.pathname + parsed.search,
162
+ headers: {},
163
+ rejectUnauthorized: false,
164
+ agent: false, // CRITICAL: fresh TCP+TLS connection every time (no keep-alive)
165
+ };
166
+
167
+ // IP-based URL: set Host header and TLS SNI so server accepts it
168
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(parsed.hostname)) {
169
+ const hostName = agentOpts?.fallbackHost || CF_HOST;
170
+ options.headers['Host'] = hostName;
171
+ options.servername = hostName;
172
+ }
173
+
174
+ // Allow custom agent (overrides agent: false) for specific cases
175
+ if (agentOpts?.httpsAgent) {
176
+ options.agent = agentOpts.httpsAgent;
177
+ }
178
+
179
+ const req = https.get(options, (res) => {
180
+ if (res.statusCode !== 200) {
181
+ req.destroy();
182
+ done(new Error(`HTTP ${res.statusCode}`));
183
+ return;
184
+ }
185
+ res.on('data', (chunk) => {
186
+ downloaded += chunk.length;
187
+ if (downloaded >= limitBytes) {
188
+ res.destroy();
189
+ done();
190
+ }
191
+ });
192
+ res.on('end', () => done());
193
+ res.on('error', (err) => done(err));
194
+ });
195
+
196
+ req.on('error', (err) => done(err));
197
+ req.setTimeout(timeoutMs, () => {
198
+ req.destroy();
199
+ done(new Error('timeout'));
200
+ });
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Last-resort single-stream download with long timeout (60s).
206
+ * Used when the multi-request test fails through a tunnel.
207
+ * Downloads a smaller amount (2MB) with a keep-alive agent for reliability.
208
+ * Returns low but valid speed rather than failing the node entirely.
209
+ */
210
+ async function rescueDownload() {
211
+ const RESCUE_BYTES = 2 * 1024 * 1024;
212
+ const rescueAgent = new https.Agent({ rejectUnauthorized: false, servername: CF_HOST, keepAlive: true });
213
+
214
+ // Try: IP with agent, hostname with agent, fallback hosts
215
+ const urls = [];
216
+ if (cachedCfIp) urls.push(`https://${cachedCfIp}/__down?bytes=${RESCUE_BYTES}`);
217
+ urls.push(`${CF_DOWN}?bytes=${RESCUE_BYTES}`);
218
+
219
+ for (const url of urls) {
220
+ try {
221
+ const r = await freshDownload(url, RESCUE_BYTES, { httpsAgent: rescueAgent }, 60000);
222
+ const mbps = bytesToMbps(r.bytes, r.seconds);
223
+ rescueAgent.destroy();
224
+ return { mbps: parseFloat(mbps.toFixed(2)), chunks: 1, adaptive: 'rescue' };
225
+ } catch {}
226
+ }
227
+
228
+ // Try fallback URLs with long timeout
229
+ for (const fb of FALLBACK_URLS) {
230
+ const ip = cachedFallbackIps[fb.host];
231
+ const targets = [];
232
+ if (ip) targets.push({ url: `https://${ip}${fb.path}`, opts: { httpsAgent: rescueAgent, fallbackHost: fb.host } });
233
+ targets.push({ url: `https://${fb.host}${fb.path}`, opts: { httpsAgent: rescueAgent } });
234
+
235
+ for (const t of targets) {
236
+ try {
237
+ const r = await freshDownload(t.url, fb.size, t.opts, 60000);
238
+ const mbps = bytesToMbps(r.bytes, r.seconds);
239
+ rescueAgent.destroy();
240
+ return { mbps: parseFloat(mbps.toFixed(2)), chunks: 1, adaptive: 'rescue-fallback', fallbackHost: fb.host };
241
+ } catch {}
242
+ }
243
+ }
244
+
245
+ rescueAgent.destroy();
246
+ return null;
247
+ }
248
+
249
+ /**
250
+ * Multi-request speed test: download N chunks sequentially, each with fresh TCP+TLS.
251
+ * Total elapsed time includes all connection overhead (handshakes compound).
252
+ * VPN latency shows up as genuinely lower effective throughput.
253
+ */
254
+ async function multiRequestMeasure(baseUrl, chunkBytes, chunkCount, agentOpts) {
255
+ let totalBytes = 0;
256
+ let successCount = 0;
257
+ const overallStart = Date.now();
258
+
259
+ for (let i = 0; i < chunkCount; i++) {
260
+ try {
261
+ const r = await freshDownload(baseUrl, chunkBytes, agentOpts);
262
+ totalBytes += r.bytes;
263
+ successCount++;
264
+ } catch {
265
+ // Allow partial success — report based on successful chunks
266
+ if (successCount === 0 && i === chunkCount - 1) {
267
+ throw new TunnelError(ErrorCodes.TUNNEL_SETUP_FAILED, 'All speed test chunks failed');
268
+ }
269
+ }
270
+ }
271
+
272
+ if (successCount === 0) throw new TunnelError(ErrorCodes.TUNNEL_SETUP_FAILED, 'All speed test chunks failed');
273
+
274
+ const totalElapsed = (Date.now() - overallStart) / 1000;
275
+ const mbps = bytesToMbps(totalBytes, totalElapsed, 2);
276
+ return { mbps, chunks: successCount, totalBytes, seconds: totalElapsed };
277
+ }
278
+
279
+ /**
280
+ * Fallback speed measurement — download a known-size file via HTTPS.
281
+ * Used when Cloudflare is unreachable through a WireGuard tunnel.
282
+ */
283
+ async function fallbackMeasure(agentOpts) {
284
+ for (const fb of FALLBACK_URLS) {
285
+ const ip = cachedFallbackIps[fb.host];
286
+ if (!ip) continue;
287
+ try {
288
+ const result = await freshDownload(
289
+ `https://${ip}${fb.path}`,
290
+ fb.size,
291
+ { ...agentOpts, fallbackHost: fb.host }
292
+ );
293
+ return { mbps: bytesToMbps(result.bytes, result.seconds, 2), chunks: 1, adaptive: 'fallback', fallbackHost: fb.host };
294
+ } catch {}
295
+ // Also try hostname directly
296
+ try {
297
+ const result = await freshDownload(
298
+ `https://${fb.host}${fb.path}`,
299
+ fb.size,
300
+ agentOpts
301
+ );
302
+ return { mbps: bytesToMbps(result.bytes, result.seconds, 2), chunks: 1, adaptive: 'fallback', fallbackHost: fb.host };
303
+ } catch {}
304
+ }
305
+ return null;
306
+ }
307
+
308
+
309
+ /**
310
+ * Direct speedtest — used for baseline and WireGuard tunnel testing.
311
+ * All traffic goes through the active network interface (WireGuard tunnel when up).
312
+ * Pre-resolves CF hostname to avoid DNS failures behind WireGuard tunnels.
313
+ *
314
+ * WireGuard note: When a WireGuard tunnel is active in full-tunnel mode, ALL
315
+ * traffic is routed through it — including this speed test. There is no separate
316
+ * "WireGuard speed test" function because `speedtestDirect()` IS the WireGuard
317
+ * speed test when called while a WireGuard tunnel is up. For V2Ray (SOCKS5 proxy),
318
+ * use `speedtestViaSocks5()` instead which explicitly routes through the proxy.
319
+ *
320
+ * Multi-request approach: 5 × 1MB sequential downloads, each with fresh TCP+TLS.
321
+ * VPN overhead (extra handshake latency per request) creates genuine speed gap.
322
+ */
323
+ export async function speedtestDirect() {
324
+ await resolveCfHost();
325
+ await resolveFallbackHosts();
326
+
327
+ // Build URL — try IP first (avoids DNS failures behind WireGuard tunnels)
328
+ function cfUrl(bytes) {
329
+ return cachedCfIp
330
+ ? `https://${cachedCfIp}/__down?bytes=${bytes}`
331
+ : `${CF_DOWN}?bytes=${bytes}`;
332
+ }
333
+ function cfUrlHostname(bytes) {
334
+ return `${CF_DOWN}?bytes=${bytes}`;
335
+ }
336
+
337
+ // Phase 1: Quick 1MB single probe
338
+ let probe;
339
+ try {
340
+ probe = await freshDownload(cfUrl(PROBE_BYTES), PROBE_BYTES, {});
341
+ } catch {
342
+ // IP failed, try hostname
343
+ try {
344
+ probe = await freshDownload(cfUrlHostname(PROBE_BYTES), PROBE_BYTES, {});
345
+ } catch {
346
+ // Cloudflare unreachable with fresh connections — try fallback targets
347
+ const fb = await fallbackMeasure({});
348
+ if (fb) return fb;
349
+ // Last resort: rescue download with keep-alive agent + 60s timeout
350
+ const rescue = await rescueDownload();
351
+ if (rescue) return rescue;
352
+ throw new TunnelError(ErrorCodes.TUNNEL_SETUP_FAILED, 'Speed test failed (CF and all fallbacks unreachable)');
353
+ }
354
+ }
355
+
356
+ const probeMbps = bytesToMbps(probe.bytes, probe.seconds, 2);
357
+
358
+ // If probe speed is low (< 3 Mbps), don't waste time on full test
359
+ if (probeMbps < 3) {
360
+ return { mbps: probeMbps, chunks: 1, adaptive: 'probe-only' };
361
+ }
362
+
363
+ // Phase 2: Multi-request test — 5 × 1MB sequential downloads
364
+ const url = cfUrl(CHUNK_BYTES);
365
+ try {
366
+ const full = await multiRequestMeasure(url, CHUNK_BYTES, CHUNK_COUNT, {});
367
+ return { mbps: full.mbps, chunks: full.chunks, adaptive: 'multi-request' };
368
+ } catch {
369
+ // Try hostname fallback
370
+ try {
371
+ const full = await multiRequestMeasure(cfUrlHostname(CHUNK_BYTES), CHUNK_BYTES, CHUNK_COUNT, {});
372
+ return { mbps: full.mbps, chunks: full.chunks, adaptive: 'multi-request' };
373
+ } catch {
374
+ // Full test failed but probe worked — return probe result
375
+ return { mbps: probeMbps, chunks: 1, adaptive: 'probe-fallback' };
376
+ }
377
+ }
378
+ }
379
+
380
+ /**
381
+ * SOCKS5 speedtest — used for V2Ray tunnel testing.
382
+ * Routes through the SOCKS5 proxy at localhost:proxyPort.
383
+ * Uses axios (not native fetch) because undici ignores the agent option for SOCKS5.
384
+ *
385
+ * IMPORTANT: Creates a fresh SocksProxyAgent per request to avoid connection
386
+ * reuse issues with V2Ray's SOCKS5 handler. Uses arraybuffer mode (not stream)
387
+ * because stream mode causes TLS handshake failures with some SOCKS5 proxies.
388
+ *
389
+ * Multi-request: 5 × 1MB sequential downloads, each with fresh SOCKS5+TCP+TLS.
390
+ */
391
+ export async function speedtestViaSocks5(testMb = 5, proxyPort = 1080, socksAuth = null) {
392
+ // Fresh agent per request — V2Ray SOCKS5 can fail with connection reuse
393
+ function makeAgent() {
394
+ const authStr = socksAuth ? `${socksAuth.user}:${socksAuth.pass}@` : '';
395
+ return new SocksProxyAgent(`socks5://${authStr}127.0.0.1:${proxyPort}`);
396
+ }
397
+
398
+ async function measure(url, bytes, timeoutMs = 30_000) {
399
+ const agent = makeAgent();
400
+ try {
401
+ const start = Date.now();
402
+ const res = await axios.get(url, {
403
+ responseType: 'arraybuffer',
404
+ timeout: timeoutMs,
405
+ httpAgent: agent,
406
+ httpsAgent: agent,
407
+ });
408
+ const downloaded = res.data.byteLength;
409
+ const elapsed = (Date.now() - start) / 1000;
410
+ if (elapsed <= 0 || downloaded === 0) throw new TunnelError(ErrorCodes.TUNNEL_SETUP_FAILED, 'Speed test: no data received');
411
+ return { bytes: downloaded, seconds: elapsed };
412
+ } finally {
413
+ agent.destroy();
414
+ }
415
+ }
416
+
417
+ // Phase 0: Quick connectivity check — verify the SOCKS5 tunnel can reach the internet at all.
418
+ // Without this, nodes with working tunnels get marked as failures just because speedtest
419
+ // targets (CF, OVH, Tele2) are blocked by the node's ISP/firewall.
420
+ //
421
+ // Retry once: V2Ray SOCKS5 binding is async and variable. Even after waiting for the port
422
+ // to accept TCP connections, the proxy pipeline may not be fully ready. A single retry
423
+ // after a 3s pause catches slow-starting nodes that would otherwise be false failures.
424
+ const CONNECTIVITY_TARGETS = [
425
+ 'https://www.google.com',
426
+ 'https://www.cloudflare.com',
427
+ 'https://one.one.one.one',
428
+ ];
429
+ let tunnelConnected = false;
430
+ for (let attempt = 0; attempt < 2 && !tunnelConnected; attempt++) {
431
+ if (attempt > 0) await new Promise(r => setTimeout(r, 3000));
432
+ for (const target of CONNECTIVITY_TARGETS) {
433
+ const agent = makeAgent();
434
+ try {
435
+ await axios.get(target, { timeout: 10_000, httpAgent: agent, httpsAgent: agent, maxRedirects: 2, validateStatus: () => true });
436
+ tunnelConnected = true;
437
+ break;
438
+ } catch {} finally { agent.destroy(); }
439
+ }
440
+ }
441
+ if (!tunnelConnected) {
442
+ throw new TunnelError(ErrorCodes.WG_NO_CONNECTIVITY, 'SOCKS5 tunnel has no internet connectivity (google/cloudflare/1.1.1.1 all unreachable after 2 attempts)');
443
+ }
444
+
445
+ // Phase 1: 1MB single probe — try CF first, then fallback targets, then rescue with 60s timeout
446
+ let probe;
447
+ let probeSource = 'cloudflare';
448
+ try {
449
+ probe = await measure(`${CF_DOWN}?bytes=${PROBE_BYTES}`, PROBE_BYTES);
450
+ } catch {
451
+ // CF download failed via SOCKS5 — try fallback download targets
452
+ let fallbackOk = false;
453
+ for (const fb of FALLBACK_URLS) {
454
+ try {
455
+ probe = await measure(`https://${fb.host}${fb.path}`, fb.size);
456
+ probeSource = fb.host;
457
+ fallbackOk = true;
458
+ break;
459
+ } catch {}
460
+ }
461
+ if (!fallbackOk) {
462
+ // Last resort: retry CF with 60s timeout (slow tunnels need more time)
463
+ try {
464
+ probe = await measure(`${CF_DOWN}?bytes=${PROBE_BYTES}`, PROBE_BYTES, 60_000);
465
+ } catch {
466
+ // Tunnel IS connected (phase 0 passed) but all download targets are blocked.
467
+ // Use a timed GET of a known page as rough speed estimate instead of giving up.
468
+ const agent = makeAgent();
469
+ try {
470
+ const start = Date.now();
471
+ const res = await axios.get('https://www.google.com', {
472
+ responseType: 'arraybuffer', timeout: 15_000,
473
+ httpAgent: agent, httpsAgent: agent,
474
+ });
475
+ const bytes = res.data.byteLength;
476
+ const elapsed = (Date.now() - start) / 1000;
477
+ if (bytes > 0 && elapsed > 0) {
478
+ return { mbps: Math.max(bytesToMbps(bytes, elapsed, 2), 0.1), chunks: 1, adaptive: 'google-fallback' };
479
+ }
480
+ } catch {} finally { agent.destroy(); }
481
+ throw new TunnelError(ErrorCodes.TUNNEL_SETUP_FAILED, 'SOCKS5 speed test failed (CF and all fallbacks unreachable)');
482
+ }
483
+ }
484
+ }
485
+
486
+ const probeMbps = bytesToMbps(probe.bytes, probe.seconds, 2);
487
+
488
+ if (probeMbps < 3) {
489
+ return { mbps: probeMbps, chunks: 1, adaptive: 'probe-only' };
490
+ }
491
+
492
+ // Phase 2: Multi-request — 5 × 1MB sequential downloads, each with fresh SOCKS5 agent
493
+ let totalBytes = 0;
494
+ let successCount = 0;
495
+ const overallStart = Date.now();
496
+
497
+ for (let i = 0; i < CHUNK_COUNT; i++) {
498
+ try {
499
+ const r = await measure(`${CF_DOWN}?bytes=${CHUNK_BYTES}`, CHUNK_BYTES);
500
+ totalBytes += r.bytes;
501
+ successCount++;
502
+ } catch {
503
+ if (successCount === 0 && i === CHUNK_COUNT - 1) {
504
+ // All failed — return probe
505
+ return { mbps: probeMbps, chunks: 1, adaptive: 'probe-fallback' };
506
+ }
507
+ }
508
+ }
509
+
510
+ if (successCount === 0) {
511
+ return { mbps: probeMbps, chunks: 1, adaptive: 'probe-fallback' };
512
+ }
513
+
514
+ const totalElapsed = (Date.now() - overallStart) / 1000;
515
+ return { mbps: bytesToMbps(totalBytes, totalElapsed, 2), chunks: successCount, adaptive: 'multi-request' };
516
+ }
517
+
518
+ /** Pre-resolve CF hostname so WireGuard DNS issues don't affect speedtests. Call once at startup. */
519
+ export { resolveCfHost };
520
+
521
+ /**
522
+ * Resolve all speedtest target IPs (Cloudflare + fallbacks).
523
+ * Used for WireGuard split tunneling — only these IPs get routed through the tunnel.
524
+ * MUST be called BEFORE installing the tunnel (DNS won't work through a dead tunnel).
525
+ */
526
+ export async function resolveSpeedtestIPs() {
527
+ await resolveCfHost();
528
+ await resolveFallbackHosts();
529
+ const ips = [];
530
+ if (cachedCfIp) ips.push(cachedCfIp);
531
+ for (const ip of Object.values(cachedFallbackIps)) {
532
+ if (ip) ips.push(ip);
533
+ }
534
+ return ips;
535
+ }
536
+
537
+ // ─── Speed Test Comparison (v25) ─────────────────────────────────────────────
538
+
539
+ /**
540
+ * Compare two speed test results. Returns delta and whether speed improved/degraded.
541
+ * Useful for before/after VPN comparison or detecting degradation.
542
+ *
543
+ * @param {object} before - SpeedResult from earlier test
544
+ * @param {object} after - SpeedResult from later test
545
+ * @returns {{ improved: boolean, degraded: boolean, delta: { downloadMbps: number, uploadMbps: number, latencyMs: number }, percentChange: { download: number, upload: number } }}
546
+ */
547
+ export function compareSpeedTests(before, after) {
548
+ const dlDelta = (after.downloadMbps || 0) - (before.downloadMbps || 0);
549
+ const ulDelta = (after.uploadMbps || 0) - (before.uploadMbps || 0);
550
+ const latDelta = (after.latencyMs || 0) - (before.latencyMs || 0);
551
+ const dlPct = before.downloadMbps > 0 ? (dlDelta / before.downloadMbps) * 100 : 0;
552
+ const ulPct = before.uploadMbps > 0 ? (ulDelta / before.uploadMbps) * 100 : 0;
553
+ return {
554
+ improved: dlDelta > 0,
555
+ degraded: dlDelta < -1, // >1 Mbps drop = degraded
556
+ delta: {
557
+ downloadMbps: parseFloat(dlDelta.toFixed(2)),
558
+ uploadMbps: parseFloat(ulDelta.toFixed(2)),
559
+ latencyMs: Math.round(latDelta),
560
+ },
561
+ percentChange: {
562
+ download: parseFloat(dlPct.toFixed(1)),
563
+ upload: parseFloat(ulPct.toFixed(1)),
564
+ },
565
+ };
566
+ }
567
+