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/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
- function openTunnel(target, proxyUrl, timeoutMs, signal) {
57
- return new Promise((resolve, reject) => {
58
- const auth = proxyAuthHeader(proxyUrl)
59
- const request = http.request({
60
- host: proxyUrl.hostname,
61
- port: proxyUrl.port !== '' ? Number(proxyUrl.port) : 80,
62
- method: 'CONNECT',
63
- path: connectTarget(target),
64
- headers: {
65
- host: connectTarget(target),
66
- ...(auth !== null ? { 'proxy-authorization': auth } : {}),
67
- },
68
- })
69
- const onAbort = () => request.destroy(new DOMException('aborted', 'AbortError'))
70
- if (signal !== undefined && signal !== null) {
71
- if (signal.aborted) onAbort()
72
- else signal.addEventListener('abort', onAbort, { once: true })
73
- }
74
- const timer = setTimeout(() => request.destroy(new DOMException('timed out', 'TimeoutError')), timeoutMs)
75
- if (typeof timer.unref === 'function') timer.unref()
76
- request.on('connect', (response, socket) => {
77
- clearTimeout(timer)
78
- if (response.statusCode !== 200) {
79
- socket.destroy()
80
- reject(new Error(`proxy CONNECT failed: HTTP ${response.statusCode}`))
81
- return
82
- }
83
- const tlsSocket = tls.connect({ socket, servername: target.hostname }, () => {
84
- if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
85
- resolve(tlsSocket)
86
- })
87
- tlsSocket.on('error', reject)
88
- })
89
- request.on('error', (error) => {
90
- clearTimeout(timer)
91
- if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
92
- reject(error)
93
- })
94
- request.end()
95
- })
96
- }
97
-
98
- function readResponse(socket, maxBytes) {
99
- return new Promise((resolve, reject) => {
100
- const chunks = []
101
- let total = 0
102
- let headerBuf = Buffer.alloc(0)
103
- let headerDone = false
104
- let settled = false
105
- let status = 0
106
- let headers = {}
107
- let truncated = false
108
- const finish = (error) => {
109
- if (settled) return
110
- settled = true
111
- socket.destroy()
112
- if (error !== undefined) reject(error)
113
- else resolve({ status, ok: status >= 200 && status < 300, body: Buffer.concat(chunks), truncated, headers })
114
- }
115
- const onBody = (buf) => {
116
- total += buf.length
117
- if (total > maxBytes) {
118
- const keep = maxBytes - (total - buf.length)
119
- if (keep > 0) chunks.push(buf.subarray(0, keep))
120
- truncated = true
121
- finish()
122
- return
123
- }
124
- chunks.push(buf)
125
- }
126
- socket.on('data', (buf) => {
127
- if (!headerDone) {
128
- headerBuf = Buffer.concat([headerBuf, buf])
129
- const idx = headerBuf.indexOf('\r\n\r\n')
130
- if (idx === -1) {
131
- if (headerBuf.length > 65536) finish(new Error('response headers too large'))
132
- return
133
- }
134
- try {
135
- const head = parseResponseHead(headerBuf.subarray(0, idx).toString('latin1'))
136
- status = head.status
137
- headers = head.headers
138
- } catch (error) {
139
- finish(error)
140
- return
141
- }
142
- headerDone = true
143
- const rest = headerBuf.subarray(idx + 4)
144
- if (rest.length > 0) onBody(rest)
145
- return
146
- }
147
- onBody(buf)
148
- })
149
- socket.on('end', () => {
150
- if (headerDone) finish()
151
- else finish(new Error('connection closed before response headers'))
152
- })
153
- socket.on('error', (error) => finish(error))
154
- })
155
- }
156
-
157
- /**
158
- * One proxied request: CONNECT tunnel for https targets, absolute-URI for
159
- * http targets. Returns the same shape as the direct fetch path.
160
- */
161
- export async function tunnelRequest(targetUrl, options) {
162
- const { method = 'GET', headers = {}, timeoutMs = 15000, proxy, signal, maxBytes = 1048576 } = options
163
- let target = new URL(targetUrl)
164
- let proxyUrl
165
- try {
166
- proxyUrl = new URL(String(proxy))
167
- } catch {
168
- // Never echo credentials: display only scheme://host:port.
169
- const display = String(proxy).replace(/\/\/([^/@]+)@/, '//')
170
- throw new Error(`invalid proxy URL: ${display.slice(0, 120)}`)
171
- }
172
-
173
- for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
174
- if (signal !== undefined && signal !== null && signal.aborted) throw new DOMException('aborted', 'AbortError')
175
-
176
- let socket
177
- if (target.protocol === 'https:') {
178
- socket = await openTunnel(target, proxyUrl, timeoutMs, signal)
179
- } else if (target.protocol === 'http:') {
180
- const auth = proxyAuthHeader(proxyUrl)
181
- socket = await new Promise((resolve, reject) => {
182
- const request = http.request({
183
- host: proxyUrl.hostname,
184
- port: proxyUrl.port !== '' ? Number(proxyUrl.port) : 80,
185
- method,
186
- path: target.toString(),
187
- headers: {
188
- host: target.host,
189
- ...(auth !== null ? { 'proxy-authorization': auth } : {}),
190
- ...headers,
191
- },
192
- })
193
- const onAbort = () => request.destroy(new DOMException('aborted', 'AbortError'))
194
- if (signal !== undefined && signal !== null) {
195
- if (signal.aborted) onAbort()
196
- else signal.addEventListener('abort', onAbort, { once: true })
197
- }
198
- const timer = setTimeout(() => request.destroy(new DOMException('timed out', 'TimeoutError')), timeoutMs)
199
- if (typeof timer.unref === 'function') timer.unref()
200
- request.on('response', (response) => {
201
- clearTimeout(timer)
202
- if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
203
- resolve({ stream: response, status: response.statusCode ?? 0, headers: response.headers })
204
- })
205
- request.on('error', (error) => {
206
- clearTimeout(timer)
207
- if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
208
- reject(error)
209
- })
210
- request.end()
211
- }).then(({ stream, status, headers }) => {
212
- // Collect the http-over-proxy body (rarely used by this plugin).
213
- return new Promise((resolve, reject) => {
214
- const chunks = []
215
- let total = 0
216
- let truncated = false
217
- stream.on('data', (buf) => {
218
- total += buf.length
219
- if (total > maxBytes) {
220
- const keep = maxBytes - (total - buf.length)
221
- if (keep > 0) chunks.push(buf.subarray(0, keep))
222
- truncated = true
223
- stream.destroy()
224
- } else chunks.push(buf)
225
- })
226
- stream.on('end', () => resolve({ status, ok: status >= 200 && status < 300, body: Buffer.concat(chunks), truncated, headers }))
227
- stream.on('error', reject)
228
- })
229
- })
230
- return socket
231
- } else {
232
- throw new Error(`unsupported target protocol: ${target.protocol}`)
233
- }
234
-
235
- if (target.protocol === 'https:') {
236
- socket.write(buildRequestHead(target, method, headers))
237
- const response = await readResponse(socket, maxBytes)
238
- const location = response.headers.location
239
- if (location !== undefined && [301, 302, 303, 307, 308].includes(response.status) && hop < MAX_REDIRECTS) {
240
- target = new URL(location, target)
241
- continue
242
- }
243
- return { status: response.status, ok: response.ok, body: response.body, truncated: response.truncated, headers: response.headers, url: target.toString() }
244
- }
245
- }
246
- throw new Error(`too many redirects (${MAX_REDIRECTS})`)
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
+ }