@wwkit/opm 1.0.14 → 1.0.16

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