Skip to content

Commit 1bad6f3

Browse files
committed
refactor: replace follow-redirects with native HTTP
1 parent f7601a5 commit 1bad6f3

4 files changed

Lines changed: 150 additions & 124 deletions

File tree

package-lock.json

Lines changed: 0 additions & 32 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,9 @@
6969
"license": "MPL-2.0",
7070
"dependencies": {
7171
"debug": "^4.4.3",
72-
"follow-redirects": "^1.16.0",
7372
"ws": "^8.21.1"
7473
},
7574
"devDependencies": {
76-
"@types/follow-redirects": "^1.14.4",
7775
"@types/node": "^26.1.1",
7876
"@types/ws": "^8.18.1",
7977
"prettier": "3.8.3",

src/handy.ts

Lines changed: 93 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import crypto, { createHash } from 'crypto'
22
import { createWriteStream, mkdirSync, rmSync } from 'node:fs'
33
import { rename } from 'node:fs/promises'
4-
import { Agent as HttpAgent } from 'node:http'
5-
import { Agent as HttpsAgent, type RequestOptions } from 'node:https'
6-
import followRedirects from 'follow-redirects'
4+
import { Agent as HttpAgent, request as httpRequest, type IncomingMessage } from 'node:http'
5+
import { Agent as HttpsAgent, request as httpsRequest, type RequestOptions } from 'node:https'
6+
import { pipeline } from 'node:stream/promises'
77
import path from 'path'
88
import { debug } from './debug.ts'
99
import { Mapper } from './mappers/index.ts'
1010
import { Disconnect, Exchange, Filter, FilterForExchange } from './types.ts'
11-
const { http: followRedirectsHttp, https: followRedirectsHttps } = followRedirects
1211

1312
export function parseAsUTCDate(val: string) {
1413
// Treat date-only and minute-level strings as UTC instead of local time.
@@ -456,44 +455,75 @@ async function requestViaFetch(method: string, url: string, options: HttpRequest
456455
}
457456
}
458457

459-
async function requestViaProxy(method: string, url: string, options: HttpRequestOptions): Promise<HttpResponse> {
460-
const requestClient = new URL(url).protocol === 'http:' ? followRedirectsHttp : followRedirectsHttps
461-
const preparedRequest = prepareRequest(method, options)
458+
type DownloadRequestOptions = {
459+
headers: Record<string, string>
460+
timeout: number
461+
}
462462

463-
return await new Promise<HttpResponse>((resolve, reject) => {
464-
const request = requestClient
465-
.request(
466-
url,
467-
{
468-
method,
469-
agent: getProxyAgent(url),
470-
headers: preparedRequest.headers,
471-
timeout: options.timeout
472-
},
473-
(response) => {
474-
response.setEncoding('utf8')
475-
let body = ''
476-
response.on('error', reject)
477-
response.on('data', (chunk) => (body += chunk))
478-
response.on('end', () => {
479-
resolve(createHttpResponse(response.statusCode ?? 0, parseNodeResponseHeaders(response.headers), body))
480-
})
481-
}
482-
)
483-
.on('error', reject)
484-
.on('timeout', () => {
485-
reject(new Error('Request timed out'))
486-
request.destroy()
487-
})
488-
489-
if (preparedRequest.body !== undefined) {
490-
request.write(preparedRequest.body)
491-
}
463+
function sendHttpRequest(url: string | URL, options: RequestOptions, body?: string): Promise<IncomingMessage> {
464+
const requestUrl = new URL(url)
465+
const requestClient = requestUrl.protocol === 'http:' ? httpRequest : httpsRequest
466+
const agent = getProxyAgent(requestUrl) ?? (requestUrl.protocol === 'https:' ? httpsAgent : undefined)
467+
468+
return new Promise((resolve, reject) => {
469+
const request = requestClient(requestUrl, { ...options, agent }, resolve)
492470

471+
request.once('error', reject)
472+
request.once('timeout', () => request.destroy(new Error('Request timed out')))
473+
if (body !== undefined) {
474+
request.write(body)
475+
}
493476
request.end()
494477
})
495478
}
496479

480+
async function openDownloadResponse(url: string, options: DownloadRequestOptions) {
481+
const response = await sendHttpRequest(url, options)
482+
const statusCode = response.statusCode ?? 0
483+
const location = response.headers.location
484+
if (location === undefined || statusCode < 300 || statusCode >= 400) {
485+
return response
486+
}
487+
488+
response.destroy()
489+
const sourceUrl = new URL(url)
490+
const redirectUrl = new URL(location, sourceUrl)
491+
const headers: Record<string, string> = { ...options.headers }
492+
if (sourceUrl.origin !== redirectUrl.origin) {
493+
delete headers.Authorization
494+
delete headers.authorization
495+
}
496+
497+
return sendHttpRequest(redirectUrl, {
498+
...options,
499+
headers
500+
})
501+
}
502+
503+
async function readResponseText(response: IncomingMessage) {
504+
response.setEncoding('utf8')
505+
let body = ''
506+
for await (const chunk of response) {
507+
body += chunk
508+
}
509+
return body
510+
}
511+
512+
async function requestViaProxy(method: string, url: string, options: HttpRequestOptions): Promise<HttpResponse> {
513+
const preparedRequest = prepareRequest(method, options)
514+
const response = await sendHttpRequest(
515+
url,
516+
{
517+
method,
518+
headers: preparedRequest.headers,
519+
timeout: options.timeout
520+
},
521+
preparedRequest.body
522+
)
523+
524+
return createHttpResponse(response.statusCode ?? 0, parseNodeResponseHeaders(response.headers), await readResponseText(response))
525+
}
526+
497527
async function request(method: string, url: string, options: HttpRequestOptions = {}) {
498528
const retrySettings = getRetrySettings(method, options.retry)
499529

@@ -622,7 +652,12 @@ export function cleanTempFiles() {
622652
tmpFileCleanups.forEach((cleanup) => cleanup())
623653
}
624654

625-
async function _downloadFile(requestOptions: RequestOptions, url: string, downloadPath: string, appendContentEncodingExtension: boolean) {
655+
async function _downloadFile(
656+
requestOptions: DownloadRequestOptions,
657+
url: string,
658+
downloadPath: string,
659+
appendContentEncodingExtension: boolean
660+
) {
626661
// first ensure that directory where we want to download file exists
627662
mkdirSync(path.dirname(downloadPath), { recursive: true })
628663

@@ -642,59 +677,28 @@ async function _downloadFile(requestOptions: RequestOptions, url: string, downlo
642677

643678
try {
644679
// based on https://github.com/nodejs/node/issues/28172 - only reliable way to consume response stream and avoiding all the 'gotchas'
645-
let responseHeaders: Record<string, string> = {}
646-
await new Promise<void>((resolve, reject) => {
647-
const protocol = new URL(url).protocol
648-
const requestClient = protocol === 'http:' ? followRedirectsHttp : followRedirectsHttps
649-
const agent = getProxyAgent(url) ?? (protocol === 'https:' ? httpsAgent : undefined)
650-
const req = requestClient
651-
.get(url, { ...requestOptions, agent }, (res) => {
652-
const { statusCode } = res
653-
if (statusCode !== 200) {
654-
// read the error response text and throw it as an HttpError
655-
res.setEncoding('utf8')
656-
let body = ''
657-
res.on('error', reject)
658-
res.on('data', (chunk) => (body += chunk))
659-
res.on('end', () => {
660-
reject(new HttpError(statusCode!, body, url))
661-
})
662-
} else {
663-
responseHeaders = parseNodeResponseHeaders(res.headers)
664-
if (appendContentEncodingExtension) {
665-
const contentEncoding = asSingleHeaderValue(res.headers['content-encoding'])
666-
if (contentEncoding === 'zstd') {
667-
finalDownloadPath = `${downloadPath}.zst`
668-
} else if (contentEncoding === undefined || contentEncoding === 'gzip') {
669-
finalDownloadPath = `${downloadPath}.gz`
670-
} else {
671-
reject(new Error(`Unsupported data feed content encoding: ${contentEncoding}`))
672-
return
673-
}
674-
}
680+
const response = await openDownloadResponse(url, requestOptions)
681+
const { statusCode } = response
682+
if (statusCode !== 200) {
683+
throw new HttpError(statusCode ?? 0, await readResponseText(response), url)
684+
}
675685

676-
// consume the response stream by writing it to the file
677-
res
678-
.on('error', reject)
679-
.on('aborted', () => reject(new Error('Request aborted')))
680-
.pipe(fileWriteStream)
681-
.on('error', reject)
682-
.on('finish', () => {
683-
if (res.complete) {
684-
resolve()
685-
} else {
686-
reject(new Error('The connection was terminated while the message was still being sent'))
687-
}
688-
})
689-
}
690-
})
691-
.on('error', reject)
692-
.on('timeout', () => {
693-
debug('download file request timeout, %s', url)
694-
reject(new Error('Request timed out'))
695-
req.destroy()
696-
})
697-
})
686+
const responseHeaders = parseNodeResponseHeaders(response.headers)
687+
if (appendContentEncodingExtension) {
688+
const contentEncoding = asSingleHeaderValue(response.headers['content-encoding'])
689+
if (contentEncoding === 'zstd') {
690+
finalDownloadPath = `${downloadPath}.zst`
691+
} else if (contentEncoding === undefined || contentEncoding === 'gzip') {
692+
finalDownloadPath = `${downloadPath}.gz`
693+
} else {
694+
throw new Error(`Unsupported data feed content encoding: ${contentEncoding}`)
695+
}
696+
}
697+
698+
await pipeline(response, fileWriteStream)
699+
if (!response.complete) {
700+
throw new Error('The connection was terminated while the message was still being sent')
701+
}
698702

699703
// finally when saving from the network to file has succeded, rename tmp file to normal name
700704
// then we're sure that responses is 100% saved and also even if different process was doing the same we're good

test/httpclient.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import { test } from 'node:test'
2+
import { execFile } from 'node:child_process'
3+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
24
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
35
import type { AddressInfo } from 'node:net'
4-
import { getJSON, postJSON } from '../dist/handy.js'
6+
import os from 'node:os'
7+
import path from 'node:path'
8+
import { pathToFileURL } from 'node:url'
9+
import { promisify } from 'node:util'
10+
import { download, getJSON, postJSON } from '../dist/handy.js'
511
import { assert } from './assertions.ts'
612

13+
const execFileAsync = promisify(execFile)
14+
715
test('retries a temporary GET failure and returns the HTTP response', async () => {
816
let requestsCount = 0
917
const server = await startServer((_request, response) => {
@@ -53,6 +61,54 @@ test('sends a JSON POST body and retries when requested', async () => {
5361
}
5462
})
5563

64+
test('follows one download redirect without forwarding authorization to another origin', { timeout: 5000 }, async () => {
65+
let redirectedAuthorization: string | undefined
66+
const target = await startServer((request, response) => {
67+
redirectedAuthorization = request.headers.authorization
68+
response.writeHead(200).end('redirected data')
69+
})
70+
const source = await startServer((request, response) => {
71+
assert.strictEqual(request.headers.authorization, 'Bearer secret')
72+
response.writeHead(302, { Location: `${target.url}/data` }).end()
73+
})
74+
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'tardis-node-http-'))
75+
const downloadPath = path.join(tempDir, 'data.bin')
76+
77+
try {
78+
await download({ url: `${source.url}/redirect`, downloadPath, userAgent: 'tardis-node-test', apiKey: 'secret' })
79+
80+
assert.strictEqual(redirectedAuthorization, undefined)
81+
assert.strictEqual(readFileSync(downloadPath, 'utf8'), 'redirected data')
82+
} finally {
83+
rmSync(tempDir, { force: true, recursive: true })
84+
await source.close()
85+
await target.close()
86+
}
87+
})
88+
89+
test('routes HTTP requests through the configured proxy', { timeout: 5000 }, async () => {
90+
let requestedUrl: string | undefined
91+
const proxy = await startServer((request, response) => {
92+
requestedUrl = request.url
93+
response.writeHead(200, { 'Content-Type': 'application/json' }).end('{"proxied":true}')
94+
})
95+
const handyModuleUrl = pathToFileURL(path.resolve('dist/handy.js')).href
96+
const script = `import { getJSON } from ${JSON.stringify(handyModuleUrl)}; process.stdout.write(JSON.stringify(await getJSON('http://exchange.test/data')))`
97+
98+
try {
99+
const { stdout } = await execFileAsync(process.execPath, ['--input-type=module', '--eval', script], {
100+
env: { ...process.env, HTTP_PROXY: proxy.url, HTTPS_PROXY: proxy.url, NO_PROXY: '' }
101+
})
102+
103+
const result = JSON.parse(stdout)
104+
assert.strictEqual(requestedUrl, 'http://exchange.test/data')
105+
assert.strictEqual(result.statusCode, 200)
106+
assert.deepStrictEqual(result.data, { proxied: true })
107+
} finally {
108+
await proxy.close()
109+
}
110+
})
111+
56112
async function startServer(handler: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>) {
57113
const server = createServer((request, response) => {
58114
void Promise.resolve(handler(request, response)).catch((error) => response.destroy(error))

0 commit comments

Comments
 (0)