dsh-github-router 0.3.1 → 0.4.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.
- package/CHANGELOG.md +79 -56
- package/CONTRIBUTING.md +17 -6
- package/README.md +26 -18
- package/README.zh.md +22 -17
- package/docs/design.md +51 -43
- package/lib/client.js +165 -116
- package/lib/config.js +167 -116
- package/lib/core/issue.js +212 -202
- package/lib/core/pr.js +454 -429
- package/lib/index.js +11 -6
- package/lib/routes/gh.js +120 -109
- package/lib/routes/html.js +266 -250
- package/lib/settings.js +23 -21
- package/lib/tunnel.js +363 -247
- package/package.json +11 -17
package/lib/tunnel.js
CHANGED
|
@@ -1,247 +1,363 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Zero-dependency proxy transport: HTTPS through an HTTP CONNECT tunnel
|
|
3
|
-
* (plus plain HTTP via absolute-URI proxying), built on node:http/tls.
|
|
4
|
-
*
|
|
5
|
-
* Why not global fetch + undici ProxyAgent: this plugin must install
|
|
6
|
-
* OFFLINE on machines whose package registry access is exactly what is
|
|
7
|
-
* broken. The tunnel client is small, carries its own cooperative timeout
|
|
8
|
-
* and caller abort, enforces the byte cap, and sends
|
|
9
|
-
* `accept-encoding: identity` so responses never need decompression.
|
|
10
|
-
* @module dsh-github-router/tunnel
|
|
11
|
-
*/
|
|
12
|
-
import http from 'node:http'
|
|
13
|
-
import tls from 'node:tls'
|
|
14
|
-
|
|
15
|
-
const MAX_REDIRECTS = 3
|
|
16
|
-
|
|
17
|
-
/** Basic auth header value for a proxy URL carrying credentials, else null. */
|
|
18
|
-
export function proxyAuthHeader(proxyUrl) {
|
|
19
|
-
if (proxyUrl.username === '' && proxyUrl.password === '') return null
|
|
20
|
-
const user = decodeURIComponent(proxyUrl.username)
|
|
21
|
-
const pass = decodeURIComponent(proxyUrl.password)
|
|
22
|
-
if (user === '' && pass === '') return null
|
|
23
|
-
return 'Basic ' + Buffer.from(`${user}:${pass}`, 'utf8').toString('base64')
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** The CONNECT authority line for a target URL. */
|
|
27
|
-
export function connectTarget(target) {
|
|
28
|
-
return `${target.hostname}:${target.port !== '' && target.port !== null ? target.port : 443}`
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Build the request-line + headers block written onto the tunnel socket. */
|
|
32
|
-
export function buildRequestHead(target, method, headers) {
|
|
33
|
-
const path = target.pathname + target.search
|
|
34
|
-
const lines = [`${method} ${path} HTTP/1.1`, `host: ${target.host}`]
|
|
35
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
36
|
-
if (value === undefined || value === null) continue
|
|
37
|
-
lines.push(`${key}: ${value}`)
|
|
38
|
-
}
|
|
39
|
-
lines.push('accept-encoding: identity', 'connection: close', '', '')
|
|
40
|
-
return lines.join('\r\n')
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Parse an HTTP response head into { status, headers }. */
|
|
44
|
-
export function parseResponseHead(headText) {
|
|
45
|
-
const lines = String(headText).split('\r\n')
|
|
46
|
-
const match = /^HTTP\/\d(?:\.\d)? (\d{3})/.exec(lines[0] ?? '')
|
|
47
|
-
if (match === null) throw new Error(`bad HTTP status line: ${(lines[0] ?? '').slice(0, 60)}`)
|
|
48
|
-
const headers = {}
|
|
49
|
-
for (const line of lines.slice(1)) {
|
|
50
|
-
const colon = line.indexOf(':')
|
|
51
|
-
if (colon > 0) headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim()
|
|
52
|
-
}
|
|
53
|
-
return { status: Number(match[1]), headers }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency proxy transport: HTTPS through an HTTP CONNECT tunnel
|
|
3
|
+
* (plus plain HTTP via absolute-URI proxying), built on node:http/tls.
|
|
4
|
+
*
|
|
5
|
+
* Why not global fetch + undici ProxyAgent: this plugin must install
|
|
6
|
+
* OFFLINE on machines whose package registry access is exactly what is
|
|
7
|
+
* broken. The tunnel client is small, carries its own cooperative timeout
|
|
8
|
+
* and caller abort, enforces the byte cap, and sends
|
|
9
|
+
* `accept-encoding: identity` so responses never need decompression.
|
|
10
|
+
* @module dsh-github-router/tunnel
|
|
11
|
+
*/
|
|
12
|
+
import http from 'node:http'
|
|
13
|
+
import tls from 'node:tls'
|
|
14
|
+
|
|
15
|
+
const MAX_REDIRECTS = 3
|
|
16
|
+
|
|
17
|
+
/** Basic auth header value for a proxy URL carrying credentials, else null. */
|
|
18
|
+
export function proxyAuthHeader(proxyUrl) {
|
|
19
|
+
if (proxyUrl.username === '' && proxyUrl.password === '') return null
|
|
20
|
+
const user = decodeURIComponent(proxyUrl.username)
|
|
21
|
+
const pass = decodeURIComponent(proxyUrl.password)
|
|
22
|
+
if (user === '' && pass === '') return null
|
|
23
|
+
return 'Basic ' + Buffer.from(`${user}:${pass}`, 'utf8').toString('base64')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The CONNECT authority line for a target URL. */
|
|
27
|
+
export function connectTarget(target) {
|
|
28
|
+
return `${target.hostname}:${target.port !== '' && target.port !== null ? target.port : 443}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build the request-line + headers block written onto the tunnel socket. */
|
|
32
|
+
export function buildRequestHead(target, method, headers) {
|
|
33
|
+
const path = target.pathname + target.search
|
|
34
|
+
const lines = [`${method} ${path} HTTP/1.1`, `host: ${target.host}`]
|
|
35
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
36
|
+
if (value === undefined || value === null) continue
|
|
37
|
+
lines.push(`${key}: ${value}`)
|
|
38
|
+
}
|
|
39
|
+
lines.push('accept-encoding: identity', 'connection: close', '', '')
|
|
40
|
+
return lines.join('\r\n')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parse an HTTP response head into { status, headers }. */
|
|
44
|
+
export function parseResponseHead(headText) {
|
|
45
|
+
const lines = String(headText).split('\r\n')
|
|
46
|
+
const match = /^HTTP\/\d(?:\.\d)? (\d{3})/.exec(lines[0] ?? '')
|
|
47
|
+
if (match === null) throw new Error(`bad HTTP status line: ${(lines[0] ?? '').slice(0, 60)}`)
|
|
48
|
+
const headers = {}
|
|
49
|
+
for (const line of lines.slice(1)) {
|
|
50
|
+
const colon = line.indexOf(':')
|
|
51
|
+
if (colon > 0) headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim()
|
|
52
|
+
}
|
|
53
|
+
return { status: Number(match[1]), headers }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True when a parsed response head declares chunked transfer encoding. */
|
|
57
|
+
export function isChunked(headers) {
|
|
58
|
+
const value = headers === null || headers === undefined ? '' : headers['transfer-encoding']
|
|
59
|
+
return typeof value === 'string' && /(?:^|[\s,])chunked(?:[\s,;]|$)/i.test(value)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Incremental `Transfer-Encoding: chunked` decoder.
|
|
64
|
+
*
|
|
65
|
+
* The tunnel reads the raw TLS socket, so the framing has to be stripped
|
|
66
|
+
* here: otherwise a proxied response keeps its size lines and trailers
|
|
67
|
+
* embedded in the body (GitHub serves issue/PR pages that way), which
|
|
68
|
+
* corrupts every JSON island and file read on that path.
|
|
69
|
+
*
|
|
70
|
+
* `onData(chunk)` receives decoded bytes and returns true to stop early
|
|
71
|
+
* (byte cap reached). `onDone()` fires after the terminating zero-length
|
|
72
|
+
* chunk and its trailer section; `onError(error)` on a malformed or
|
|
73
|
+
* truncated stream. `end()` must be called when the socket closes.
|
|
74
|
+
*/
|
|
75
|
+
export function createChunkedDecoder(onData, onDone, onError) {
|
|
76
|
+
let state = 'size' // size | data | dataEnd | trailer | done
|
|
77
|
+
let remaining = 0
|
|
78
|
+
let pending = Buffer.alloc(0)
|
|
79
|
+
|
|
80
|
+
const fail = (message) => {
|
|
81
|
+
if (state === 'done') return
|
|
82
|
+
state = 'done'
|
|
83
|
+
onError(new Error(message))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
push(buf) {
|
|
88
|
+
if (state === 'done' || buf.length === 0) return
|
|
89
|
+
pending = pending.length === 0 ? buf : Buffer.concat([pending, buf])
|
|
90
|
+
while (state !== 'done') {
|
|
91
|
+
if (state === 'size') {
|
|
92
|
+
const idx = pending.indexOf('\r\n')
|
|
93
|
+
if (idx === -1) {
|
|
94
|
+
if (pending.length > 1024) fail('chunk size line too long')
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
const line = pending.subarray(0, idx).toString('latin1')
|
|
98
|
+
pending = pending.subarray(idx + 2)
|
|
99
|
+
const size = line.split(';')[0].trim() // chunk extensions are legal
|
|
100
|
+
if (!/^[0-9a-fA-F]+$/.test(size)) {
|
|
101
|
+
fail(`invalid chunk size: ${line.slice(0, 40)}`)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
remaining = Number.parseInt(size, 16)
|
|
105
|
+
state = remaining === 0 ? 'trailer' : 'data'
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (state === 'data') {
|
|
109
|
+
if (pending.length === 0) return
|
|
110
|
+
const take = Math.min(remaining, pending.length)
|
|
111
|
+
const piece = pending.subarray(0, take)
|
|
112
|
+
pending = pending.subarray(take)
|
|
113
|
+
remaining -= take
|
|
114
|
+
if (onData(piece) === true) {
|
|
115
|
+
state = 'done'
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
if (remaining === 0) state = 'dataEnd'
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
if (state === 'dataEnd') {
|
|
122
|
+
if (pending.length < 2) return
|
|
123
|
+
if (pending[0] !== 0x0d || pending[1] !== 0x0a) {
|
|
124
|
+
fail('malformed chunked body: missing CRLF after chunk data')
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
pending = pending.subarray(2)
|
|
128
|
+
state = 'size'
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
// trailer section: header lines until the terminating empty line
|
|
132
|
+
const idx = pending.indexOf('\r\n')
|
|
133
|
+
if (idx === -1) {
|
|
134
|
+
if (pending.length > 65536) fail('chunk trailer too large')
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
const line = pending.subarray(0, idx).toString('latin1')
|
|
138
|
+
pending = pending.subarray(idx + 2)
|
|
139
|
+
if (line.length === 0) {
|
|
140
|
+
state = 'done'
|
|
141
|
+
onDone()
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
end() {
|
|
147
|
+
fail('connection closed before the chunked body completed')
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function openTunnel(target, proxyUrl, timeoutMs, signal) {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const auth = proxyAuthHeader(proxyUrl)
|
|
155
|
+
const request = http.request({
|
|
156
|
+
host: proxyUrl.hostname,
|
|
157
|
+
port: proxyUrl.port !== '' ? Number(proxyUrl.port) : 80,
|
|
158
|
+
method: 'CONNECT',
|
|
159
|
+
path: connectTarget(target),
|
|
160
|
+
headers: {
|
|
161
|
+
host: connectTarget(target),
|
|
162
|
+
...(auth !== null ? { 'proxy-authorization': auth } : {}),
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
const onAbort = () => request.destroy(new DOMException('aborted', 'AbortError'))
|
|
166
|
+
if (signal !== undefined && signal !== null) {
|
|
167
|
+
if (signal.aborted) onAbort()
|
|
168
|
+
else signal.addEventListener('abort', onAbort, { once: true })
|
|
169
|
+
}
|
|
170
|
+
const timer = setTimeout(() => request.destroy(new DOMException('timed out', 'TimeoutError')), timeoutMs)
|
|
171
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
172
|
+
request.on('connect', (response, socket) => {
|
|
173
|
+
clearTimeout(timer)
|
|
174
|
+
if (response.statusCode !== 200) {
|
|
175
|
+
socket.destroy()
|
|
176
|
+
reject(new Error(`proxy CONNECT failed: HTTP ${response.statusCode}`))
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
const tlsSocket = tls.connect({ socket, servername: target.hostname }, () => {
|
|
180
|
+
if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
|
|
181
|
+
resolve(tlsSocket)
|
|
182
|
+
})
|
|
183
|
+
tlsSocket.on('error', reject)
|
|
184
|
+
})
|
|
185
|
+
request.on('error', (error) => {
|
|
186
|
+
clearTimeout(timer)
|
|
187
|
+
if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
|
|
188
|
+
reject(error)
|
|
189
|
+
})
|
|
190
|
+
request.end()
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function readResponse(socket, maxBytes) {
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
const chunks = []
|
|
197
|
+
let total = 0
|
|
198
|
+
let headerBuf = Buffer.alloc(0)
|
|
199
|
+
let headerDone = false
|
|
200
|
+
let settled = false
|
|
201
|
+
let status = 0
|
|
202
|
+
let headers = {}
|
|
203
|
+
let truncated = false
|
|
204
|
+
let decoder = null
|
|
205
|
+
const finish = (error) => {
|
|
206
|
+
if (settled) return
|
|
207
|
+
settled = true
|
|
208
|
+
socket.destroy()
|
|
209
|
+
if (error !== undefined) reject(error)
|
|
210
|
+
else resolve({ status, ok: status >= 200 && status < 300, body: Buffer.concat(chunks), truncated, headers })
|
|
211
|
+
}
|
|
212
|
+
const onBody = (buf) => {
|
|
213
|
+
total += buf.length
|
|
214
|
+
if (total > maxBytes) {
|
|
215
|
+
const keep = maxBytes - (total - buf.length)
|
|
216
|
+
if (keep > 0) chunks.push(buf.subarray(0, keep))
|
|
217
|
+
truncated = true
|
|
218
|
+
finish()
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
chunks.push(buf)
|
|
222
|
+
return false
|
|
223
|
+
}
|
|
224
|
+
const feed = (buf) => {
|
|
225
|
+
if (buf.length === 0) return
|
|
226
|
+
if (decoder !== null) decoder.push(buf)
|
|
227
|
+
else onBody(buf)
|
|
228
|
+
}
|
|
229
|
+
socket.on('data', (buf) => {
|
|
230
|
+
if (!headerDone) {
|
|
231
|
+
headerBuf = Buffer.concat([headerBuf, buf])
|
|
232
|
+
const idx = headerBuf.indexOf('\r\n\r\n')
|
|
233
|
+
if (idx === -1) {
|
|
234
|
+
if (headerBuf.length > 65536) finish(new Error('response headers too large'))
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const head = parseResponseHead(headerBuf.subarray(0, idx).toString('latin1'))
|
|
239
|
+
status = head.status
|
|
240
|
+
headers = head.headers
|
|
241
|
+
} catch (error) {
|
|
242
|
+
finish(error)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
headerDone = true
|
|
246
|
+
if (isChunked(headers)) {
|
|
247
|
+
decoder = createChunkedDecoder(
|
|
248
|
+
(piece) => onBody(piece),
|
|
249
|
+
() => finish(),
|
|
250
|
+
(error) => finish(error),
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
feed(headerBuf.subarray(idx + 4))
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
feed(buf)
|
|
257
|
+
})
|
|
258
|
+
socket.on('end', () => {
|
|
259
|
+
if (!headerDone) {
|
|
260
|
+
finish(new Error('connection closed before response headers'))
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
if (decoder !== null) {
|
|
264
|
+
decoder.end()
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
finish()
|
|
268
|
+
})
|
|
269
|
+
socket.on('error', (error) => finish(error))
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* One proxied request: CONNECT tunnel for https targets, absolute-URI for
|
|
275
|
+
* http targets. Returns the same shape as the direct fetch path.
|
|
276
|
+
*/
|
|
277
|
+
export async function tunnelRequest(targetUrl, options) {
|
|
278
|
+
const { method = 'GET', headers = {}, timeoutMs = 15000, proxy, signal, maxBytes = 1048576 } = options
|
|
279
|
+
let target = new URL(targetUrl)
|
|
280
|
+
let proxyUrl
|
|
281
|
+
try {
|
|
282
|
+
proxyUrl = new URL(String(proxy))
|
|
283
|
+
} catch {
|
|
284
|
+
// Never echo credentials: display only scheme://host:port.
|
|
285
|
+
const display = String(proxy).replace(/\/\/([^/@]+)@/, '//')
|
|
286
|
+
throw new Error(`invalid proxy URL: ${display.slice(0, 120)}`)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
290
|
+
if (signal !== undefined && signal !== null && signal.aborted) throw new DOMException('aborted', 'AbortError')
|
|
291
|
+
|
|
292
|
+
let socket
|
|
293
|
+
if (target.protocol === 'https:') {
|
|
294
|
+
socket = await openTunnel(target, proxyUrl, timeoutMs, signal)
|
|
295
|
+
} else if (target.protocol === 'http:') {
|
|
296
|
+
const auth = proxyAuthHeader(proxyUrl)
|
|
297
|
+
socket = await new Promise((resolve, reject) => {
|
|
298
|
+
const request = http.request({
|
|
299
|
+
host: proxyUrl.hostname,
|
|
300
|
+
port: proxyUrl.port !== '' ? Number(proxyUrl.port) : 80,
|
|
301
|
+
method,
|
|
302
|
+
path: target.toString(),
|
|
303
|
+
headers: {
|
|
304
|
+
host: target.host,
|
|
305
|
+
...(auth !== null ? { 'proxy-authorization': auth } : {}),
|
|
306
|
+
...headers,
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
const onAbort = () => request.destroy(new DOMException('aborted', 'AbortError'))
|
|
310
|
+
if (signal !== undefined && signal !== null) {
|
|
311
|
+
if (signal.aborted) onAbort()
|
|
312
|
+
else signal.addEventListener('abort', onAbort, { once: true })
|
|
313
|
+
}
|
|
314
|
+
const timer = setTimeout(() => request.destroy(new DOMException('timed out', 'TimeoutError')), timeoutMs)
|
|
315
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
316
|
+
request.on('response', (response) => {
|
|
317
|
+
clearTimeout(timer)
|
|
318
|
+
if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
|
|
319
|
+
resolve({ stream: response, status: response.statusCode ?? 0, headers: response.headers })
|
|
320
|
+
})
|
|
321
|
+
request.on('error', (error) => {
|
|
322
|
+
clearTimeout(timer)
|
|
323
|
+
if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
|
|
324
|
+
reject(error)
|
|
325
|
+
})
|
|
326
|
+
request.end()
|
|
327
|
+
}).then(({ stream, status, headers }) => {
|
|
328
|
+
// Collect the http-over-proxy body (rarely used by this plugin).
|
|
329
|
+
return new Promise((resolve, reject) => {
|
|
330
|
+
const chunks = []
|
|
331
|
+
let total = 0
|
|
332
|
+
let truncated = false
|
|
333
|
+
stream.on('data', (buf) => {
|
|
334
|
+
total += buf.length
|
|
335
|
+
if (total > maxBytes) {
|
|
336
|
+
const keep = maxBytes - (total - buf.length)
|
|
337
|
+
if (keep > 0) chunks.push(buf.subarray(0, keep))
|
|
338
|
+
truncated = true
|
|
339
|
+
stream.destroy()
|
|
340
|
+
} else chunks.push(buf)
|
|
341
|
+
})
|
|
342
|
+
stream.on('end', () => resolve({ status, ok: status >= 200 && status < 300, body: Buffer.concat(chunks), truncated, headers }))
|
|
343
|
+
stream.on('error', reject)
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
return socket
|
|
347
|
+
} else {
|
|
348
|
+
throw new Error(`unsupported target protocol: ${target.protocol}`)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (target.protocol === 'https:') {
|
|
352
|
+
socket.write(buildRequestHead(target, method, headers))
|
|
353
|
+
const response = await readResponse(socket, maxBytes)
|
|
354
|
+
const location = response.headers.location
|
|
355
|
+
if (location !== undefined && [301, 302, 303, 307, 308].includes(response.status) && hop < MAX_REDIRECTS) {
|
|
356
|
+
target = new URL(location, target)
|
|
357
|
+
continue
|
|
358
|
+
}
|
|
359
|
+
return { status: response.status, ok: response.ok, body: response.body, truncated: response.truncated, headers: response.headers, url: target.toString() }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
throw new Error(`too many redirects (${MAX_REDIRECTS})`)
|
|
363
|
+
}
|