@wwkit/shared 1.0.28 → 1.0.29
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/LICENSE +20 -20
- package/README.md +34 -34
- package/package.json +1 -1
- package/src/args.js +79 -79
- package/src/config-loader.js +250 -250
- package/src/http.js +482 -482
- package/src/index.js +4 -4
- package/src/release.js +282 -215
- package/src/shell.js +569 -569
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
|
+
}
|