@wwkit/shared 1.0.29 → 1.0.31

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/src/http.js CHANGED
@@ -1,482 +1,482 @@
1
- /**
2
- * 共享 HTTP 工具(支持代理隧道)
3
- *
4
- * 统一 wwkit monorepo 各子包的 HTTP 请求、文件下载、代理隧道实现:
5
- * - parseProxy: 解析代理 URL
6
- * - openTunnel: 建立代理隧道 socket(SOCKS5 + HTTP CONNECT)
7
- * - fetchText: 获取 URL 文本内容(支持代理 + 重定向)
8
- * - downloadToFile: 下载文件到本地(支持代理 + 进度 + 重定向)
9
- *
10
- * 无代理时使用 native fetch;有代理时根据代理协议建立隧道:
11
- * - http(s):// HTTP 代理:HTTPS 目标走 CONNECT 隧道,HTTP 目标直接以绝对 URI 转发
12
- * - socks5(h):// Socks5 代理:握手后隧道转发(支持无认证 / username-password 认证)
13
- */
14
-
15
- import fs from 'node:fs'
16
- import http from 'node:http'
17
- import net from 'node:net'
18
- import https from 'node:https'
19
- import tls from 'node:tls'
20
-
21
- const DEFAULT_TIMEOUT = 15000
22
-
23
- /**
24
- * 解析代理 URL 字符串
25
- * @param {string} raw - 代理 URL(如 socks5://user:pass@host:1080)
26
- * @returns {URL|null} URL 对象,无效时返回 null
27
- */
28
- export function parseProxy(raw) {
29
- if (!raw) return null
30
- try {
31
- const u = new URL(raw)
32
- if (!u.hostname) return null
33
- if (u.protocol !== 'http:' && u.protocol !== 'https:' &&
34
- u.protocol !== 'socks5:' && u.protocol !== 'socks:') {
35
- return null
36
- }
37
- return u
38
- } catch {
39
- return null
40
- }
41
- }
42
-
43
- /**
44
- * 建立裸 TCP 连接
45
- * @param {string} host
46
- * @param {number} port
47
- * @param {number} timeout
48
- * @returns {Promise<import('node:net').Socket>}
49
- * @private
50
- */
51
- function openTcp(host, port, timeout) {
52
- return new Promise((resolve, reject) => {
53
- const socket = net.connect({ host, port })
54
- socket.setTimeout(timeout)
55
- socket.setNoDelay(true)
56
- socket.once('connect', () => {
57
- socket.setTimeout(0)
58
- resolve(socket)
59
- })
60
- socket.once('error', reject)
61
- socket.once('timeout', () => {
62
- socket.destroy()
63
- reject(new Error('tcp connect timeout'))
64
- })
65
- })
66
- }
67
-
68
- /**
69
- * 对已连接的 socket 执行 TLS 握手
70
- * @param {import('node:net').Socket} socket
71
- * @param {string} host - SNI
72
- * @param {number} timeout
73
- * @returns {Promise<import('node:tls').TLSSocket>}
74
- * @private
75
- */
76
- function wrapTls(socket, host, timeout) {
77
- return new Promise((resolve, reject) => {
78
- const tlsSocket = tls.connect({ socket, servername: host, timeout }, () => {
79
- tlsSocket.setTimeout(0)
80
- resolve(tlsSocket)
81
- })
82
- tlsSocket.once('error', (err) => {
83
- socket.destroy()
84
- reject(err)
85
- })
86
- tlsSocket.once('timeout', () => {
87
- tlsSocket.destroy()
88
- reject(new Error('tls handshake timeout'))
89
- })
90
- })
91
- }
92
-
93
- /**
94
- * 通过 HTTP 代理建立 CONNECT 隧道
95
- * @param {URL} proxyUrl
96
- * @param {string} host
97
- * @param {number} port
98
- * @param {number} timeout
99
- * @returns {Promise<import('node:net').Socket>}
100
- * @private
101
- */
102
- function httpConnect(proxyUrl, host, port, timeout) {
103
- return new Promise((resolve, reject) => {
104
- const req = http.request({
105
- host: proxyUrl.hostname,
106
- port: proxyUrl.port || 80,
107
- method: 'CONNECT',
108
- path: `${host}:${port}`,
109
- timeout
110
- })
111
- req.once('connect', (res, socket) => {
112
- if (res.statusCode !== 200) {
113
- socket.destroy()
114
- reject(new Error(`CONNECT failed: ${res.statusCode}`))
115
- return
116
- }
117
- socket.setTimeout(0)
118
- resolve(socket)
119
- })
120
- req.once('error', reject)
121
- req.once('timeout', () => {
122
- req.destroy()
123
- reject(new Error('CONNECT timeout'))
124
- })
125
- req.end()
126
- })
127
- }
128
-
129
- /**
130
- * 通过 Socks5 代理建立隧道(RFC 1928)
131
- *
132
- * 支持三种地址类型(DOMAIN/IPv4/IPv6)与两种认证方式:
133
- * 无认证(0x00)、username/password(0x02,RFC 1929)。
134
- * 代理 URL 带用户名密码时自动使用 0x02 认证。
135
- *
136
- * @param {URL} proxyUrl - socks5://host:port 或 socks5://user:pass@host:port
137
- * @param {string} host - 目标主机名
138
- * @param {number} port - 目标端口
139
- * @param {number} timeout
140
- * @returns {Promise<import('node:net').Socket>}
141
- * @private
142
- */
143
- function socks5Connect(proxyUrl, host, port, timeout) {
144
- const auth = proxyUrl.username
145
- ? {
146
- user: decodeURIComponent(proxyUrl.username),
147
- pass: decodeURIComponent(proxyUrl.password || '')
148
- }
149
- : null
150
-
151
- return openTcp(proxyUrl.hostname, proxyUrl.port || 1080, timeout).then(
152
- (socket) =>
153
- new Promise((resolve, reject) => {
154
- const fail = (err) => {
155
- socket.destroy()
156
- reject(err instanceof Error ? err : new Error(String(err)))
157
- }
158
-
159
- const methods = auth ? [0x00, 0x02] : [0x00]
160
- socket.write(Buffer.from([0x05, methods.length, ...methods]))
161
-
162
- let stage = 'greeting'
163
- let buf = Buffer.alloc(0)
164
-
165
- const sendConnect = () => {
166
- stage = 'connect'
167
- const hostBuf = Buffer.from(host, 'utf8')
168
- const req = Buffer.alloc(7 + hostBuf.length)
169
- req[0] = 0x05
170
- req[1] = 0x01
171
- req[2] = 0x00
172
- req[3] = 0x03
173
- req[4] = hostBuf.length
174
- hostBuf.copy(req, 5)
175
- req.writeUInt16BE(port, 5 + hostBuf.length)
176
- socket.write(req)
177
- }
178
-
179
- const onData = (chunk) => {
180
- buf = Buffer.concat([buf, chunk])
181
- try {
182
- if (stage === 'greeting') {
183
- if (buf.length < 2) return
184
- const method = buf.readUInt8(1)
185
- buf = buf.subarray(2)
186
- if (method === 0xff) {
187
- throw new Error('socks5: no acceptable auth method')
188
- }
189
- if (method === 0x02) {
190
- if (!auth) {
191
- throw new Error('socks5: server requires auth, but none provided')
192
- }
193
- const userBuf = Buffer.from(auth.user, 'utf8')
194
- const passBuf = Buffer.from(auth.pass, 'utf8')
195
- const req = Buffer.alloc(3 + userBuf.length + passBuf.length)
196
- req[0] = 0x01
197
- req[1] = userBuf.length
198
- userBuf.copy(req, 2)
199
- req[2 + userBuf.length] = passBuf.length
200
- passBuf.copy(req, 3 + userBuf.length)
201
- socket.write(req)
202
- stage = 'auth'
203
- return
204
- }
205
- sendConnect()
206
- return
207
- }
208
- if (stage === 'auth') {
209
- if (buf.length < 2) return
210
- if (buf.readUInt8(1) !== 0x00) {
211
- throw new Error('socks5: username/password auth failed')
212
- }
213
- buf = buf.subarray(2)
214
- sendConnect()
215
- return
216
- }
217
- if (stage === 'connect') {
218
- if (buf.length < 4) return
219
- if (buf.readUInt8(0) !== 0x05) {
220
- throw new Error('socks5: invalid reply version')
221
- }
222
- const rep = buf.readUInt8(1)
223
- if (rep !== 0x00) {
224
- throw new Error(`socks5: connect failed with code ${rep}`)
225
- }
226
- const atyp = buf.readUInt8(3)
227
- let addrLen
228
- if (atyp === 0x01) addrLen = 4
229
- else if (atyp === 0x03) addrLen = buf.readUInt8(4) + 1
230
- else if (atyp === 0x04) addrLen = 16
231
- else {
232
- throw new Error('socks5: invalid address type')
233
- }
234
- const total = 4 + addrLen + 2
235
- if (buf.length < total) return
236
- cleanup()
237
- socket.setTimeout(0)
238
- resolve(socket)
239
- }
240
- } catch (err) {
241
- cleanup()
242
- fail(err)
243
- }
244
- }
245
-
246
- const cleanup = () => {
247
- socket.removeListener('data', onData)
248
- socket.removeListener('error', onError)
249
- socket.removeListener('timeout', onTimeout)
250
- }
251
-
252
- const onError = (err) => {
253
- cleanup()
254
- fail(err)
255
- }
256
-
257
- const onTimeout = () => {
258
- cleanup()
259
- fail(new Error('socks5: timeout'))
260
- }
261
-
262
- socket.setTimeout(timeout)
263
- socket.on('data', onData)
264
- socket.on('error', onError)
265
- socket.on('timeout', onTimeout)
266
- })
267
- )
268
- }
269
-
270
- /**
271
- * 建立到目标主机的隧道 socket(HTTPS 目标会完成 TLS 握手)
272
- *
273
- * @param {URL} proxyUrl - 代理 URL(已解析)
274
- * @param {string} scheme - 代理协议(http/https/socks5/socks5h)
275
- * @param {string} host - 目标主机名
276
- * @param {number} port - 目标端口
277
- * @param {boolean} isHttps - 目标是否为 HTTPS
278
- * @param {number} timeout - 超时(ms)
279
- * @returns {Promise<import('node:net').Socket>}
280
- * @private
281
- */
282
- function tunnelConnect(proxyUrl, scheme, host, port, isHttps, timeout) {
283
- if (scheme === 'socks5' || scheme === 'socks5h' || scheme === 'socks') {
284
- return socks5Connect(proxyUrl, host, port, timeout).then((socket) =>
285
- isHttps ? wrapTls(socket, host, timeout) : socket
286
- )
287
- }
288
- if (scheme === 'http' || scheme === 'https') {
289
- if (isHttps) {
290
- return httpConnect(proxyUrl, host, port, timeout).then((socket) =>
291
- wrapTls(socket, host, timeout)
292
- )
293
- }
294
- return openTcp(proxyUrl.hostname, proxyUrl.port || 80, timeout)
295
- }
296
- return Promise.reject(new Error(`Unsupported proxy scheme: ${scheme}`))
297
- }
298
-
299
- /**
300
- * 建立到目标 URL 的代理隧道 socket
301
- *
302
- * 高层接口:传入代理 URL 字符串和目标 URL,返回已建立隧道的 socket
303
- * (HTTPS 目标会完成 TLS 握手)。
304
- *
305
- * @param {URL|string} proxy - 代理 URL(字符串或已解析的 URL 对象)
306
- * @param {URL|string} target - 目标 URL(字符串或已解析的 URL 对象)
307
- * @param {number} [timeout=15000] - 超时(ms)
308
- * @returns {Promise<import('node:net').Socket>}
309
- */
310
- export function openTunnel(proxy, target, timeout = DEFAULT_TIMEOUT) {
311
- const proxyUrl = typeof proxy === 'string' ? new URL(proxy) : proxy
312
- const targetUrl = typeof target === 'string' ? new URL(target) : target
313
- const scheme = proxyUrl.protocol.replace(/:$/, '')
314
- const isHttps = targetUrl.protocol === 'https:'
315
- const port = Number(targetUrl.port) || (isHttps ? 443 : 80)
316
- return tunnelConnect(proxyUrl, scheme, targetUrl.hostname, port, isHttps, timeout)
317
- }
318
-
319
- /**
320
- * 把响应流写入文件(附进度回调)
321
- * @param {import('node:http').IncomingMessage} res
322
- * @param {string} destPath
323
- * @param {string} url
324
- * @param {(pct: number) => void} [onProgress]
325
- * @returns {Promise<void>}
326
- * @private
327
- */
328
- function pipeToFile(res, destPath, url, onProgress) {
329
- return new Promise((resolve, reject) => {
330
- const file = fs.createWriteStream(destPath)
331
- const total = parseInt(res.headers['content-length'] || '0', 10)
332
- let written = 0
333
- let lastPct = 0
334
- res.on('data', (chunk) => {
335
- written += chunk.length
336
- if (total > 0 && onProgress) {
337
- const pct = Math.floor(written * 100 / total)
338
- if (pct >= lastPct + 10 || written === total) {
339
- lastPct = pct
340
- onProgress(pct)
341
- }
342
- }
343
- })
344
- res.pipe(file)
345
- file.on('finish', () => { file.close(); resolve() })
346
- file.on('error', reject)
347
- res.on('error', reject)
348
- })
349
- }
350
-
351
- /**
352
- * 通过代理或直连把 URL 内容流式下载到文件
353
- *
354
- * @param {string} url - 请求 URL
355
- * @param {string} destPath - 目标文件路径
356
- * @param {{ proxy?: string, timeout?: number, onProgress?: (pct: number) => void }} [opts]
357
- * @returns {Promise<void>}
358
- */
359
- export async function downloadToFile(url, destPath, { proxy, timeout = 600000, onProgress } = {}) {
360
- const target = new URL(url)
361
- const isHttps = target.protocol === 'https:'
362
- const targetPort = target.port || (isHttps ? 443 : 80)
363
-
364
- if (!proxy) {
365
- await new Promise((resolve, reject) => {
366
- const proto = isHttps ? https : http
367
- const req = proto.get(url, { timeout }, (res) => {
368
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
369
- res.resume()
370
- const next = new URL(res.headers.location, url).href
371
- downloadToFile(next, destPath, { proxy, timeout, onProgress }).then(resolve, reject)
372
- return
373
- }
374
- if (res.statusCode !== 200) {
375
- reject(new Error(`HTTP ${res.statusCode}: ${url}`))
376
- return
377
- }
378
- pipeToFile(res, destPath, url, onProgress).then(resolve, reject)
379
- })
380
- req.on('error', reject)
381
- req.on('timeout', () => { req.destroy(); reject(new Error(`download timeout: ${url}`)) })
382
- })
383
- return
384
- }
385
-
386
- const proxyUrl = new URL(proxy)
387
- const scheme = proxyUrl.protocol.replace(/:$/, '')
388
-
389
- await new Promise((resolve, reject) => {
390
- tunnelConnect(proxyUrl, scheme, target.hostname, targetPort, isHttps, timeout)
391
- .then((socket) => {
392
- const isProxiedPlainHttp = !isHttps && (scheme === 'http' || scheme === 'https')
393
- const req = http.request({
394
- createConnection: () => socket,
395
- method: 'GET',
396
- path: isProxiedPlainHttp ? url : target.pathname + target.search,
397
- headers: { Host: target.host },
398
- timeout,
399
- }, (res) => {
400
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
401
- res.resume()
402
- const next = new URL(res.headers.location, url).href
403
- downloadToFile(next, destPath, { proxy, timeout, onProgress }).then(resolve, reject)
404
- return
405
- }
406
- if (res.statusCode !== 200) {
407
- reject(new Error(`HTTP ${res.statusCode}: ${url}`))
408
- return
409
- }
410
- pipeToFile(res, destPath, url, onProgress).then(resolve, reject)
411
- })
412
- req.on('error', reject)
413
- req.on('timeout', () => { req.destroy(); reject(new Error(`download timeout: ${url}`)) })
414
- req.end()
415
- })
416
- .catch(reject)
417
- })
418
- }
419
-
420
- /**
421
- * 通过代理或直连获取 URL 文本内容
422
- *
423
- * @param {string} url - 请求 URL
424
- * @param {{ proxy?: string, timeout?: number, maxRedirects?: number }} [opts] - 选项
425
- * @returns {Promise<string|null>}
426
- */
427
- export async function fetchText(url, { proxy, timeout = DEFAULT_TIMEOUT, maxRedirects = 5 } = {}) {
428
- if (!proxy) {
429
- try {
430
- const resp = await fetch(url, { signal: AbortSignal.timeout(timeout) })
431
- if (!resp.ok) return null
432
- return await resp.text()
433
- } catch {
434
- return null
435
- }
436
- }
437
-
438
- const target = new URL(url)
439
- const proxyUrl = new URL(proxy)
440
- const isHttps = target.protocol === 'https:'
441
- const targetPort = target.port || (isHttps ? 443 : 80)
442
- const scheme = proxyUrl.protocol.replace(/:$/, '')
443
-
444
- return new Promise((resolve) => {
445
- if (maxRedirects <= 0) {
446
- resolve(null)
447
- return
448
- }
449
-
450
- tunnelConnect(proxyUrl, scheme, target.hostname, targetPort, isHttps, timeout)
451
- .then((socket) => {
452
- const isProxiedPlainHttp = !isHttps && (scheme === 'http' || scheme === 'https')
453
-
454
- const req = http.request({
455
- createConnection: () => socket,
456
- method: 'GET',
457
- path: isProxiedPlainHttp ? url : target.pathname + target.search,
458
- headers: { Host: target.host },
459
- timeout
460
- }, (response) => {
461
- if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
462
- const redirectUrl = new URL(response.headers.location, url).href
463
- fetchText(redirectUrl, { proxy, timeout, maxRedirects: maxRedirects - 1 }).then(resolve)
464
- return
465
- }
466
- if (response.statusCode < 200 || response.statusCode >= 300) {
467
- resolve(null)
468
- return
469
- }
470
- let data = ''
471
- response.setEncoding('utf8')
472
- response.on('data', (chunk) => { data += chunk })
473
- response.on('end', () => resolve(data))
474
- })
475
-
476
- req.on('error', () => resolve(null))
477
- req.on('timeout', () => { req.destroy(); resolve(null) })
478
- req.end()
479
- })
480
- .catch(() => resolve(null))
481
- })
482
- }
1
+ /**
2
+ * 共享 HTTP 工具(支持代理隧道)
3
+ *
4
+ * 统一 wwkit monorepo 各子包的 HTTP 请求、文件下载、代理隧道实现:
5
+ * - parseProxy: 解析代理 URL
6
+ * - openTunnel: 建立代理隧道 socket(SOCKS5 + HTTP CONNECT)
7
+ * - fetchText: 获取 URL 文本内容(支持代理 + 重定向)
8
+ * - downloadToFile: 下载文件到本地(支持代理 + 进度 + 重定向)
9
+ *
10
+ * 无代理时使用 native fetch;有代理时根据代理协议建立隧道:
11
+ * - http(s):// HTTP 代理:HTTPS 目标走 CONNECT 隧道,HTTP 目标直接以绝对 URI 转发
12
+ * - socks5(h):// Socks5 代理:握手后隧道转发(支持无认证 / username-password 认证)
13
+ */
14
+
15
+ import fs from 'node:fs'
16
+ import http from 'node:http'
17
+ import net from 'node:net'
18
+ import https from 'node:https'
19
+ import tls from 'node:tls'
20
+
21
+ const DEFAULT_TIMEOUT = 15000
22
+
23
+ /**
24
+ * 解析代理 URL 字符串
25
+ * @param {string} raw - 代理 URL(如 socks5://user:pass@host:1080)
26
+ * @returns {URL|null} URL 对象,无效时返回 null
27
+ */
28
+ export function parseProxy(raw) {
29
+ if (!raw) return null
30
+ try {
31
+ const u = new URL(raw)
32
+ if (!u.hostname) return null
33
+ if (u.protocol !== 'http:' && u.protocol !== 'https:' &&
34
+ u.protocol !== 'socks5:' && u.protocol !== 'socks:') {
35
+ return null
36
+ }
37
+ return u
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ /**
44
+ * 建立裸 TCP 连接
45
+ * @param {string} host
46
+ * @param {number} port
47
+ * @param {number} timeout
48
+ * @returns {Promise<import('node:net').Socket>}
49
+ * @private
50
+ */
51
+ function openTcp(host, port, timeout) {
52
+ return new Promise((resolve, reject) => {
53
+ const socket = net.connect({ host, port })
54
+ socket.setTimeout(timeout)
55
+ socket.setNoDelay(true)
56
+ socket.once('connect', () => {
57
+ socket.setTimeout(0)
58
+ resolve(socket)
59
+ })
60
+ socket.once('error', reject)
61
+ socket.once('timeout', () => {
62
+ socket.destroy()
63
+ reject(new Error('tcp connect timeout'))
64
+ })
65
+ })
66
+ }
67
+
68
+ /**
69
+ * 对已连接的 socket 执行 TLS 握手
70
+ * @param {import('node:net').Socket} socket
71
+ * @param {string} host - SNI
72
+ * @param {number} timeout
73
+ * @returns {Promise<import('node:tls').TLSSocket>}
74
+ * @private
75
+ */
76
+ function wrapTls(socket, host, timeout) {
77
+ return new Promise((resolve, reject) => {
78
+ const tlsSocket = tls.connect({ socket, servername: host, timeout }, () => {
79
+ tlsSocket.setTimeout(0)
80
+ resolve(tlsSocket)
81
+ })
82
+ tlsSocket.once('error', (err) => {
83
+ socket.destroy()
84
+ reject(err)
85
+ })
86
+ tlsSocket.once('timeout', () => {
87
+ tlsSocket.destroy()
88
+ reject(new Error('tls handshake timeout'))
89
+ })
90
+ })
91
+ }
92
+
93
+ /**
94
+ * 通过 HTTP 代理建立 CONNECT 隧道
95
+ * @param {URL} proxyUrl
96
+ * @param {string} host
97
+ * @param {number} port
98
+ * @param {number} timeout
99
+ * @returns {Promise<import('node:net').Socket>}
100
+ * @private
101
+ */
102
+ function httpConnect(proxyUrl, host, port, timeout) {
103
+ return new Promise((resolve, reject) => {
104
+ const req = http.request({
105
+ host: proxyUrl.hostname,
106
+ port: proxyUrl.port || 80,
107
+ method: 'CONNECT',
108
+ path: `${host}:${port}`,
109
+ timeout
110
+ })
111
+ req.once('connect', (res, socket) => {
112
+ if (res.statusCode !== 200) {
113
+ socket.destroy()
114
+ reject(new Error(`CONNECT failed: ${res.statusCode}`))
115
+ return
116
+ }
117
+ socket.setTimeout(0)
118
+ resolve(socket)
119
+ })
120
+ req.once('error', reject)
121
+ req.once('timeout', () => {
122
+ req.destroy()
123
+ reject(new Error('CONNECT timeout'))
124
+ })
125
+ req.end()
126
+ })
127
+ }
128
+
129
+ /**
130
+ * 通过 Socks5 代理建立隧道(RFC 1928)
131
+ *
132
+ * 支持三种地址类型(DOMAIN/IPv4/IPv6)与两种认证方式:
133
+ * 无认证(0x00)、username/password(0x02,RFC 1929)。
134
+ * 代理 URL 带用户名密码时自动使用 0x02 认证。
135
+ *
136
+ * @param {URL} proxyUrl - socks5://host:port 或 socks5://user:pass@host:port
137
+ * @param {string} host - 目标主机名
138
+ * @param {number} port - 目标端口
139
+ * @param {number} timeout
140
+ * @returns {Promise<import('node:net').Socket>}
141
+ * @private
142
+ */
143
+ function socks5Connect(proxyUrl, host, port, timeout) {
144
+ const auth = proxyUrl.username
145
+ ? {
146
+ user: decodeURIComponent(proxyUrl.username),
147
+ pass: decodeURIComponent(proxyUrl.password || '')
148
+ }
149
+ : null
150
+
151
+ return openTcp(proxyUrl.hostname, proxyUrl.port || 1080, timeout).then(
152
+ (socket) =>
153
+ new Promise((resolve, reject) => {
154
+ const fail = (err) => {
155
+ socket.destroy()
156
+ reject(err instanceof Error ? err : new Error(String(err)))
157
+ }
158
+
159
+ const methods = auth ? [0x00, 0x02] : [0x00]
160
+ socket.write(Buffer.from([0x05, methods.length, ...methods]))
161
+
162
+ let stage = 'greeting'
163
+ let buf = Buffer.alloc(0)
164
+
165
+ const sendConnect = () => {
166
+ stage = 'connect'
167
+ const hostBuf = Buffer.from(host, 'utf8')
168
+ const req = Buffer.alloc(7 + hostBuf.length)
169
+ req[0] = 0x05
170
+ req[1] = 0x01
171
+ req[2] = 0x00
172
+ req[3] = 0x03
173
+ req[4] = hostBuf.length
174
+ hostBuf.copy(req, 5)
175
+ req.writeUInt16BE(port, 5 + hostBuf.length)
176
+ socket.write(req)
177
+ }
178
+
179
+ const onData = (chunk) => {
180
+ buf = Buffer.concat([buf, chunk])
181
+ try {
182
+ if (stage === 'greeting') {
183
+ if (buf.length < 2) return
184
+ const method = buf.readUInt8(1)
185
+ buf = buf.subarray(2)
186
+ if (method === 0xff) {
187
+ throw new Error('socks5: no acceptable auth method')
188
+ }
189
+ if (method === 0x02) {
190
+ if (!auth) {
191
+ throw new Error('socks5: server requires auth, but none provided')
192
+ }
193
+ const userBuf = Buffer.from(auth.user, 'utf8')
194
+ const passBuf = Buffer.from(auth.pass, 'utf8')
195
+ const req = Buffer.alloc(3 + userBuf.length + passBuf.length)
196
+ req[0] = 0x01
197
+ req[1] = userBuf.length
198
+ userBuf.copy(req, 2)
199
+ req[2 + userBuf.length] = passBuf.length
200
+ passBuf.copy(req, 3 + userBuf.length)
201
+ socket.write(req)
202
+ stage = 'auth'
203
+ return
204
+ }
205
+ sendConnect()
206
+ return
207
+ }
208
+ if (stage === 'auth') {
209
+ if (buf.length < 2) return
210
+ if (buf.readUInt8(1) !== 0x00) {
211
+ throw new Error('socks5: username/password auth failed')
212
+ }
213
+ buf = buf.subarray(2)
214
+ sendConnect()
215
+ return
216
+ }
217
+ if (stage === 'connect') {
218
+ if (buf.length < 4) return
219
+ if (buf.readUInt8(0) !== 0x05) {
220
+ throw new Error('socks5: invalid reply version')
221
+ }
222
+ const rep = buf.readUInt8(1)
223
+ if (rep !== 0x00) {
224
+ throw new Error(`socks5: connect failed with code ${rep}`)
225
+ }
226
+ const atyp = buf.readUInt8(3)
227
+ let addrLen
228
+ if (atyp === 0x01) addrLen = 4
229
+ else if (atyp === 0x03) addrLen = buf.readUInt8(4) + 1
230
+ else if (atyp === 0x04) addrLen = 16
231
+ else {
232
+ throw new Error('socks5: invalid address type')
233
+ }
234
+ const total = 4 + addrLen + 2
235
+ if (buf.length < total) return
236
+ cleanup()
237
+ socket.setTimeout(0)
238
+ resolve(socket)
239
+ }
240
+ } catch (err) {
241
+ cleanup()
242
+ fail(err)
243
+ }
244
+ }
245
+
246
+ const cleanup = () => {
247
+ socket.removeListener('data', onData)
248
+ socket.removeListener('error', onError)
249
+ socket.removeListener('timeout', onTimeout)
250
+ }
251
+
252
+ const onError = (err) => {
253
+ cleanup()
254
+ fail(err)
255
+ }
256
+
257
+ const onTimeout = () => {
258
+ cleanup()
259
+ fail(new Error('socks5: timeout'))
260
+ }
261
+
262
+ socket.setTimeout(timeout)
263
+ socket.on('data', onData)
264
+ socket.on('error', onError)
265
+ socket.on('timeout', onTimeout)
266
+ })
267
+ )
268
+ }
269
+
270
+ /**
271
+ * 建立到目标主机的隧道 socket(HTTPS 目标会完成 TLS 握手)
272
+ *
273
+ * @param {URL} proxyUrl - 代理 URL(已解析)
274
+ * @param {string} scheme - 代理协议(http/https/socks5/socks5h)
275
+ * @param {string} host - 目标主机名
276
+ * @param {number} port - 目标端口
277
+ * @param {boolean} isHttps - 目标是否为 HTTPS
278
+ * @param {number} timeout - 超时(ms)
279
+ * @returns {Promise<import('node:net').Socket>}
280
+ * @private
281
+ */
282
+ function tunnelConnect(proxyUrl, scheme, host, port, isHttps, timeout) {
283
+ if (scheme === 'socks5' || scheme === 'socks5h' || scheme === 'socks') {
284
+ return socks5Connect(proxyUrl, host, port, timeout).then((socket) =>
285
+ isHttps ? wrapTls(socket, host, timeout) : socket
286
+ )
287
+ }
288
+ if (scheme === 'http' || scheme === 'https') {
289
+ if (isHttps) {
290
+ return httpConnect(proxyUrl, host, port, timeout).then((socket) =>
291
+ wrapTls(socket, host, timeout)
292
+ )
293
+ }
294
+ return openTcp(proxyUrl.hostname, proxyUrl.port || 80, timeout)
295
+ }
296
+ return Promise.reject(new Error(`Unsupported proxy scheme: ${scheme}`))
297
+ }
298
+
299
+ /**
300
+ * 建立到目标 URL 的代理隧道 socket
301
+ *
302
+ * 高层接口:传入代理 URL 字符串和目标 URL,返回已建立隧道的 socket
303
+ * (HTTPS 目标会完成 TLS 握手)。
304
+ *
305
+ * @param {URL|string} proxy - 代理 URL(字符串或已解析的 URL 对象)
306
+ * @param {URL|string} target - 目标 URL(字符串或已解析的 URL 对象)
307
+ * @param {number} [timeout=15000] - 超时(ms)
308
+ * @returns {Promise<import('node:net').Socket>}
309
+ */
310
+ export function openTunnel(proxy, target, timeout = DEFAULT_TIMEOUT) {
311
+ const proxyUrl = typeof proxy === 'string' ? new URL(proxy) : proxy
312
+ const targetUrl = typeof target === 'string' ? new URL(target) : target
313
+ const scheme = proxyUrl.protocol.replace(/:$/, '')
314
+ const isHttps = targetUrl.protocol === 'https:'
315
+ const port = Number(targetUrl.port) || (isHttps ? 443 : 80)
316
+ return tunnelConnect(proxyUrl, scheme, targetUrl.hostname, port, isHttps, timeout)
317
+ }
318
+
319
+ /**
320
+ * 把响应流写入文件(附进度回调)
321
+ * @param {import('node:http').IncomingMessage} res
322
+ * @param {string} destPath
323
+ * @param {string} url
324
+ * @param {(pct: number) => void} [onProgress]
325
+ * @returns {Promise<void>}
326
+ * @private
327
+ */
328
+ function pipeToFile(res, destPath, url, onProgress) {
329
+ return new Promise((resolve, reject) => {
330
+ const file = fs.createWriteStream(destPath)
331
+ const total = parseInt(res.headers['content-length'] || '0', 10)
332
+ let written = 0
333
+ let lastPct = 0
334
+ res.on('data', (chunk) => {
335
+ written += chunk.length
336
+ if (total > 0 && onProgress) {
337
+ const pct = Math.floor(written * 100 / total)
338
+ if (pct >= lastPct + 10 || written === total) {
339
+ lastPct = pct
340
+ onProgress(pct)
341
+ }
342
+ }
343
+ })
344
+ res.pipe(file)
345
+ file.on('finish', () => { file.close(); resolve() })
346
+ file.on('error', reject)
347
+ res.on('error', reject)
348
+ })
349
+ }
350
+
351
+ /**
352
+ * 通过代理或直连把 URL 内容流式下载到文件
353
+ *
354
+ * @param {string} url - 请求 URL
355
+ * @param {string} destPath - 目标文件路径
356
+ * @param {{ proxy?: string, timeout?: number, onProgress?: (pct: number) => void }} [opts]
357
+ * @returns {Promise<void>}
358
+ */
359
+ export async function downloadToFile(url, destPath, { proxy, timeout = 600000, onProgress } = {}) {
360
+ const target = new URL(url)
361
+ const isHttps = target.protocol === 'https:'
362
+ const targetPort = target.port || (isHttps ? 443 : 80)
363
+
364
+ if (!proxy) {
365
+ await new Promise((resolve, reject) => {
366
+ const proto = isHttps ? https : http
367
+ const req = proto.get(url, { timeout }, (res) => {
368
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
369
+ res.resume()
370
+ const next = new URL(res.headers.location, url).href
371
+ downloadToFile(next, destPath, { proxy, timeout, onProgress }).then(resolve, reject)
372
+ return
373
+ }
374
+ if (res.statusCode !== 200) {
375
+ reject(new Error(`HTTP ${res.statusCode}: ${url}`))
376
+ return
377
+ }
378
+ pipeToFile(res, destPath, url, onProgress).then(resolve, reject)
379
+ })
380
+ req.on('error', reject)
381
+ req.on('timeout', () => { req.destroy(); reject(new Error(`download timeout: ${url}`)) })
382
+ })
383
+ return
384
+ }
385
+
386
+ const proxyUrl = new URL(proxy)
387
+ const scheme = proxyUrl.protocol.replace(/:$/, '')
388
+
389
+ await new Promise((resolve, reject) => {
390
+ tunnelConnect(proxyUrl, scheme, target.hostname, targetPort, isHttps, timeout)
391
+ .then((socket) => {
392
+ const isProxiedPlainHttp = !isHttps && (scheme === 'http' || scheme === 'https')
393
+ const req = http.request({
394
+ createConnection: () => socket,
395
+ method: 'GET',
396
+ path: isProxiedPlainHttp ? url : target.pathname + target.search,
397
+ headers: { Host: target.host },
398
+ timeout,
399
+ }, (res) => {
400
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
401
+ res.resume()
402
+ const next = new URL(res.headers.location, url).href
403
+ downloadToFile(next, destPath, { proxy, timeout, onProgress }).then(resolve, reject)
404
+ return
405
+ }
406
+ if (res.statusCode !== 200) {
407
+ reject(new Error(`HTTP ${res.statusCode}: ${url}`))
408
+ return
409
+ }
410
+ pipeToFile(res, destPath, url, onProgress).then(resolve, reject)
411
+ })
412
+ req.on('error', reject)
413
+ req.on('timeout', () => { req.destroy(); reject(new Error(`download timeout: ${url}`)) })
414
+ req.end()
415
+ })
416
+ .catch(reject)
417
+ })
418
+ }
419
+
420
+ /**
421
+ * 通过代理或直连获取 URL 文本内容
422
+ *
423
+ * @param {string} url - 请求 URL
424
+ * @param {{ proxy?: string, timeout?: number, maxRedirects?: number }} [opts] - 选项
425
+ * @returns {Promise<string|null>}
426
+ */
427
+ export async function fetchText(url, { proxy, timeout = DEFAULT_TIMEOUT, maxRedirects = 5 } = {}) {
428
+ if (!proxy) {
429
+ try {
430
+ const resp = await fetch(url, { signal: AbortSignal.timeout(timeout) })
431
+ if (!resp.ok) return null
432
+ return await resp.text()
433
+ } catch {
434
+ return null
435
+ }
436
+ }
437
+
438
+ const target = new URL(url)
439
+ const proxyUrl = new URL(proxy)
440
+ const isHttps = target.protocol === 'https:'
441
+ const targetPort = target.port || (isHttps ? 443 : 80)
442
+ const scheme = proxyUrl.protocol.replace(/:$/, '')
443
+
444
+ return new Promise((resolve) => {
445
+ if (maxRedirects <= 0) {
446
+ resolve(null)
447
+ return
448
+ }
449
+
450
+ tunnelConnect(proxyUrl, scheme, target.hostname, targetPort, isHttps, timeout)
451
+ .then((socket) => {
452
+ const isProxiedPlainHttp = !isHttps && (scheme === 'http' || scheme === 'https')
453
+
454
+ const req = http.request({
455
+ createConnection: () => socket,
456
+ method: 'GET',
457
+ path: isProxiedPlainHttp ? url : target.pathname + target.search,
458
+ headers: { Host: target.host },
459
+ timeout
460
+ }, (response) => {
461
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
462
+ const redirectUrl = new URL(response.headers.location, url).href
463
+ fetchText(redirectUrl, { proxy, timeout, maxRedirects: maxRedirects - 1 }).then(resolve)
464
+ return
465
+ }
466
+ if (response.statusCode < 200 || response.statusCode >= 300) {
467
+ resolve(null)
468
+ return
469
+ }
470
+ let data = ''
471
+ response.setEncoding('utf8')
472
+ response.on('data', (chunk) => { data += chunk })
473
+ response.on('end', () => resolve(data))
474
+ })
475
+
476
+ req.on('error', () => resolve(null))
477
+ req.on('timeout', () => { req.destroy(); resolve(null) })
478
+ req.end()
479
+ })
480
+ .catch(() => resolve(null))
481
+ })
482
+ }