From bebf8f522c3659f8b83304340a17e8cb8e958062 Mon Sep 17 00:00:00 2001 From: "marcus.salinas" <12.marcus.salinas@gmail.com> Date: Tue, 28 Apr 2026 20:08:24 -0500 Subject: [PATCH] harden vanillaFallback compatibility fallback --- impit-node/index.wrapper.js | 71 ++- impit-node/test/basics.test.ts | 250 +++++++++ impit-node/test/vanilla-fallback.helpers.ts | 81 +++ impit-node/vanilla-fallback.js | 539 ++++++++++++++++++++ 4 files changed, 935 insertions(+), 6 deletions(-) create mode 100644 impit-node/test/vanilla-fallback.helpers.ts create mode 100644 impit-node/vanilla-fallback.js diff --git a/impit-node/index.wrapper.js b/impit-node/index.wrapper.js index d91db141..511a5f34 100644 --- a/impit-node/index.wrapper.js +++ b/impit-node/index.wrapper.js @@ -1,5 +1,9 @@ const errors = require('./errors.js'); const { rethrowNativeError } = errors; +const { + VanillaFallbackController, + cloneHeadersWithSetCookie, +} = require('./vanilla-fallback.js'); let native = null; try { native = require('./index.js'); @@ -58,19 +62,36 @@ class Impit extends native.Impit { #cookieJar; #followRedirects; #maxRedirects; + #vanillaFallbackEnabled; + #vanillaClient = null; + #vanillaClientOptions; + #vanillaFallbackController; constructor(options) { + const normalizedOptions = { + ...options, + headers: canonicalizeHeaders(options?.headers), + }; + // Pass options to native. When cookieJar is provided, pass a truthy value // to signal that JS handles cookies (actual cookie ops happen in JS). // Redirects are always handled in JS layer. - super({ - ...options, - headers: canonicalizeHeaders(options?.headers), - }); + super(normalizedOptions); this.#cookieJar = options?.cookieJar; this.#followRedirects = options?.followRedirects ?? true; this.#maxRedirects = options?.maxRedirects ?? 20; + this.#vanillaFallbackEnabled = Boolean(options?.browser && options?.vanillaFallback); + this.#vanillaFallbackController = this.#vanillaFallbackEnabled + ? new VanillaFallbackController(options) + : null; + this.#vanillaClientOptions = this.#vanillaFallbackEnabled + ? { + ...normalizedOptions, + browser: undefined, + vanillaFallback: false, + } + : null; } /** @@ -242,6 +263,44 @@ class Impit extends native.Impit { }; } + async #fetchWithCompatibilityTransport(rawUrl, options) { + return this.#vanillaFallbackController.fetchWithCompatibilityTransport(rawUrl, options); + } + + #getVanillaClient() { + if (!this.#vanillaFallbackEnabled) { + return null; + } + + if (!this.#vanillaClient) { + this.#vanillaClient = new native.Impit(this.#vanillaClientOptions); + } + + return this.#vanillaClient; + } + + async #fetchWithVanillaFallback(url, options) { + try { + return await super.fetch(url, options); + } catch (error) { + if (!this.#vanillaFallbackEnabled || !this.#vanillaFallbackController?.shouldRetry(error)) { + throw error; + } + + const parsedUrl = new URL(url); + if (this.#vanillaFallbackController.canUseCompatibilityTransport(parsedUrl)) { + return this.#fetchWithCompatibilityTransport(url, options); + } + + const vanillaClient = this.#getVanillaClient(); + if (!vanillaClient) { + throw error; + } + + return vanillaClient.fetch(url, options); + } + } + async fetch(resource, init) { const { url: initialUrl, signal, redirect, ...options } = await this.#parseFetchOptions(resource, init); @@ -294,7 +353,7 @@ class Impit extends native.Impit { } } - const response = super.fetch(url, { + const response = this.#fetchWithVanillaFallback(url, { ...options, method, headers, @@ -394,7 +453,7 @@ class Impit extends native.Impit { }); Object.defineProperty(originalResponse, 'headers', { - value: new Headers(originalResponse.headers) + value: cloneHeadersWithSetCookie(originalResponse.headers) }); Object.defineProperty(originalResponse, 'clone', { diff --git a/impit-node/test/basics.test.ts b/impit-node/test/basics.test.ts index 1ae36cdd..03ff6ff1 100644 --- a/impit-node/test/basics.test.ts +++ b/impit-node/test/basics.test.ts @@ -1,9 +1,16 @@ import http from 'http'; +import https from 'https'; import { test, describe, expect, beforeAll, afterAll } from 'vitest'; import { HttpMethod, Impit, Browser } from '../index.wrapper.js'; import type { AddressInfo, Server } from 'net'; import { routes, runProxyServer, runServer } from './mock.server.js'; +import { + createTransportFailure, + createWrappedResponse, + LOCALHOST_TLS_CERT, + LOCALHOST_TLS_KEY, +} from './vanilla-fallback.helpers.js'; import { CookieJar } from 'tough-cookie'; import { runSocksServer } from 'socks-server-lib'; @@ -863,3 +870,246 @@ describe.each([ }); }) }); + +describe('vanillaFallback', () => { + test('retries with the compatibility transport and preserves per-request options', async () => { + const nativePrototype = Object.getPrototypeOf(Impit.prototype); + const originalFetch = nativePrototype.fetch; + const cookieJar = { + async getCookieString() { + return 'session=abc'; + }, + async setCookie() {}, + }; + const callers: Array<{ + isWrapper: boolean; + headers: [string, string][]; + timeout: number | undefined; + }> = []; + + nativePrototype.fetch = async function (url: string, init?: any) { + callers.push({ + isWrapper: this instanceof Impit, + headers: [...(init?.headers ?? [])], + timeout: init?.timeout, + }); + + if (this instanceof Impit) { + throw createTransportFailure(); + } + + return originalFetch.call(this, url, init); + }; + + try { + const impit = new Impit({ + browser: Browser.Chrome, + vanillaFallback: true, + cookieJar, + }); + const response = await impit.fetch(getHttpBinUrl('/headers'), { + headers: { 'Impit-Test': 'foo' }, + timeout: 1234, + }); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(callers).toHaveLength(1); + expect(callers[0]?.isWrapper).toBe(true); + expect(callers[0]?.headers).toContainEqual(['Impit-Test', 'foo']); + expect(callers[0]?.headers).toContainEqual(['Cookie', 'session=abc']); + expect(callers[0]?.timeout).toBe(1234); + expect(json.headers?.['Impit-Test']).toBe('foo'); + expect(json.headers?.Cookie).toBe('session=abc'); + expect(json.headers?.['Accept-Encoding']).toBe('gzip, deflate, br'); + } finally { + nativePrototype.fetch = originalFetch; + } + }); + + test('uses the compatibility transport through an HTTP proxy', async () => { + const nativePrototype = Object.getPrototypeOf(Impit.prototype); + const originalFetch = nativePrototype.fetch; + let wrapperCalls = 0; + + nativePrototype.fetch = async function () { + wrapperCalls += 1; + throw createTransportFailure(); + }; + + try { + const impit = new Impit({ + browser: Browser.Chrome, + vanillaFallback: true, + proxyUrl: 'http://localhost:3002', + }); + const response = await impit.fetch(getHttpBinUrl('/get')); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(wrapperCalls).toBe(1); + expect(json).toHaveProperty('url'); + expect(json).toHaveProperty('headers'); + } finally { + nativePrototype.fetch = originalFetch; + } + }); + + test('aborts compatibility fallback requests and closes the socket', async () => { + const nativePrototype = Object.getPrototypeOf(Impit.prototype); + const originalFetch = nativePrototype.fetch; + let clientSocketClosed = false; + let resolveConnectionSeen!: () => void; + let resolveSocketClosed!: () => void; + const connectionSeen = new Promise((resolve) => { + resolveConnectionSeen = resolve; + }); + const socketClosed = new Promise((resolve) => { + resolveSocketClosed = resolve; + }); + const hangingServer = https.createServer({ + key: LOCALHOST_TLS_KEY, + cert: LOCALHOST_TLS_CERT, + }, (_req, res) => { + setTimeout(() => { + res.end('late'); + }, 1000); + }); + hangingServer.on('secureConnection', (socket) => { + resolveConnectionSeen(); + socket.on('close', () => { + clientSocketClosed = true; + resolveSocketClosed(); + }); + }); + + await new Promise((resolve) => hangingServer.listen(0, '127.0.0.1', () => resolve())); + const port = (hangingServer.address() as AddressInfo).port; + + nativePrototype.fetch = async function () { + throw createTransportFailure(); + }; + + try { + const impit = new Impit({ + browser: Browser.Chrome, + vanillaFallback: true, + ignoreTlsErrors: true, + }); + + await expect( + impit.fetch(`https://127.0.0.1:${port}/`, { + signal: AbortSignal.timeout(200), + }), + ).rejects.toBeTruthy(); + + await Promise.race([ + connectionSeen, + new Promise((_, reject) => setTimeout(() => reject(new Error('socket was never opened')), 1000)), + ]); + await Promise.race([ + socketClosed, + new Promise((_, reject) => setTimeout(() => reject(new Error('socket was not closed after abort')), 1000)), + ]); + expect(clientSocketClosed).toBe(true); + } finally { + nativePrototype.fetch = originalFetch; + hangingServer.closeAllConnections?.(); + await new Promise((resolve, reject) => + hangingServer.close((error) => (error ? reject(error) : resolve())), + ); + } + }, 10_000); + + test('delegates to the native vanilla client when compatibility transport cannot preserve proxy semantics', async () => { + const nativePrototype = Object.getPrototypeOf(Impit.prototype); + const originalFetch = nativePrototype.fetch; + const callers: boolean[] = []; + + nativePrototype.fetch = async function (url: string) { + callers.push(this instanceof Impit); + + if (this instanceof Impit) { + throw createTransportFailure(); + } + + return createWrappedResponse('vanilla ok', url); + }; + + try { + const impit = new Impit({ + browser: Browser.Chrome, + vanillaFallback: true, + proxyUrl: 'socks5://localhost:7625', + }); + + const response = await impit.fetch('https://example.com/'); + + expect(await response.text()).toBe('vanilla ok'); + expect(callers).toEqual([true, false]); + } finally { + nativePrototype.fetch = originalFetch; + } + }); + + test('does not retry post-send transport failures', async () => { + const nativePrototype = Object.getPrototypeOf(Impit.prototype); + const originalFetch = nativePrototype.fetch; + const originalHttpRequest = http.request; + const originalHttpsRequest = https.request; + let compatibilityTransportCalls = 0; + + nativePrototype.fetch = async function () { + throw new Error('ReadError: Failed to read data from the server.'); + }; + http.request = ((...args: Parameters) => { + compatibilityTransportCalls += 1; + return originalHttpRequest(...args); + }) as typeof http.request; + https.request = ((...args: Parameters) => { + compatibilityTransportCalls += 1; + return originalHttpsRequest(...args); + }) as typeof https.request; + + try { + const impit = new Impit({ + browser: Browser.Chrome, + vanillaFallback: true, + }); + + await expect(impit.fetch('https://example.com/')).rejects.toThrow( + /Failed to read data from the server/i, + ); + expect(compatibilityTransportCalls).toBe(0); + } finally { + nativePrototype.fetch = originalFetch; + http.request = originalHttpRequest; + https.request = originalHttpsRequest; + } + }); + + test('does not retry when vanillaFallback is disabled', async () => { + const nativePrototype = Object.getPrototypeOf(Impit.prototype); + const originalFetch = nativePrototype.fetch; + let callCount = 0; + + nativePrototype.fetch = async function () { + callCount += 1; + throw createTransportFailure(); + }; + + try { + const impit = new Impit({ + browser: Browser.Chrome, + vanillaFallback: false, + }); + + await expect(impit.fetch('http://localhost:3001/get')).rejects.toThrow( + /internal HTTP library/i, + ); + expect(callCount).toBe(1); + } finally { + nativePrototype.fetch = originalFetch; + } + }); +}); diff --git a/impit-node/test/vanilla-fallback.helpers.ts b/impit-node/test/vanilla-fallback.helpers.ts new file mode 100644 index 00000000..e204c514 --- /dev/null +++ b/impit-node/test/vanilla-fallback.helpers.ts @@ -0,0 +1,81 @@ +export function createTransportFailure(): Error { + return new Error( + 'HTTPError: The internal HTTP library has thrown an error:\n' + + 'reqwest::Error { kind: Request, source: hyper_util::client::legacy::Error(' + + 'SendRequest, hyper::Error(Io, Custom { kind: ConnectionReset, error: "Connection reset by peer" })) }', + ); +} + +export function createWrappedResponse(body: string, url: string): Response { + const bytes = Buffer.from(body); + const response = new Response(bytes, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + + Object.defineProperty(response, 'bytes', { + value: async () => new Uint8Array(bytes), + configurable: true, + }); + Object.defineProperty(response, 'decodeBuffer', { + value: (buffer: ArrayBuffer | ArrayBufferView) => Buffer.from(buffer as any).toString('utf8'), + configurable: true, + }); + Object.defineProperty(response, 'abort', { + value: () => {}, + configurable: true, + }); + Object.defineProperty(response, 'url', { + value: url, + configurable: true, + }); + + return response; +} + +export const LOCALHOST_TLS_KEY = `-----BEGIN PRIVATE KEY----- +MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQDMxR6FpXcONRoz +9XgftbFKMJCuoGsa0eIooASG/PIUzkl08UhqM+60UgjwQX/HnA/ElXqBamnmXyK0 +drO6t+RoatX4YCkJ4E0EVjOQgoZe1R6/tld1MT69OUkndPm0D4amTVYrW8xuBQk/ +y3DOiBNVkRc4VaMgdHd3VnnFH+HZFHdADyh3kchQ/vxj4MarCTcONZIjzrkqoK1y +c6Hi2gCFN3gGypVVerNMKh24qYRwqVknKiMzRnCudqwQeeFysyHwYMujVw7/PAJS +fV9s1G3Ui3bQmQcGivFELC2SK7dGcr5tK5MBioVLbVkHN8hAwUSZiOJAAEKItzPl +cpGoPSOJAgMBAAECggEBAME+ZYeKl8h4pLnUNgD23tE8881Y5rrwx5W/LYaWv36T +Dw+lhMl1KRhTMsxJg+VEijzjNDFd04Ls1TupqgPT92HzMOqtFQ2U+BnXn+IIy/ZC ++jnCQtb+Gk9I+Jib8+rRnCjlYySYBVzus8PYoiTGljhyLI+lgcTnJLcijNhTNjg9 +P4YkUK8OqNs9TMjs7vb7dDDsBi+hBYVlpnt6Bnc/rh8BxL2z6ikHXYvitMKHozzj +rVvLZyMbfC1sdHAyEz2YpHgcW2+ePRqc/+/Yy5BmutZSGrxpGMH9Uo0hhNtjkbe7 +gzcl/6ztR7eozap2mXZllg/Vg8MFvFhAY7zK6YOH3AECgYEA8FFRp29/iGzqBwOH +5mmo0wgJH5DbtiM3L7Yqk12/sS86NQp87KMcNiCPRYs/8M0NfLr61vdNI9zBL0IU +kBgiTfYXY8oNPGhjqSC9qEpPxuM0qHpv9BpYhRdegWs1gqHOoi68HuoAaDnSS73D +skop8cxwzYDcjRUQxxnY8bNG6BkCgYEA2iHzauXVr3myLla8tqYQggo2axu+gXZt +pbtbKWccE2fv7z5tnS+ZChZxv3x4FvLBIzAuF/RONLV15uph/gIz+Crk01GMqLr2 +kgexGSVKSvc+9b9phj51bOSy16SXpUgIw1LDEd2JkIPP0Avxs7Hq8xS0ITpqJMmX +zjGP3QZGRPECgYEAjiEKEeS3oJAJuSw1a+iBmI3gF3Ms/oPFV8p9U7rWbIxp+ITD +bZDqVnjbQ14f6uLbXzGWuRx52wPsnW6Pisk7QLCTFMmjGl8C0jwy7x1EIXSu6BXB +sLUENXKkyhYGB8R62SCa0g3DP+EypukMnJ2QQRmQfXoA9s/GpHp8/DXzccECgYEA +nM6bNdVS73oEZNtlfceTRmghBo5DPL3txJ4Swoik3i5xhQLTuZNl6KKJ0qWfjp+j +x6/y8rVlIu7vergzCW57/YKYTHDrNMByUDfHT9RGu+1RDUg0i5SKxWUCS5K+kMpf +wknUgRtIsOKQmXZ8ojjcNTJE6z4a36crwcZPLQw9p4ECgYEAtCGgy3prNRaU4Ykh +SSBsz64u4hrMWdHG+UdzVabnFpcKQAGrqkX9bZvYe0yx9G0vBv/TiDTVPZcGXxT+ +ghz/U85A1Z/epyvSJzhzMTK9VxrH9pTECNTJTz5FOp2FlU1Eqv1GpJedBtH57+fg +4t42YFuPOLNLfQCsHLCUJsTPMnI= +-----END PRIVATE KEY-----`; + +export const LOCALHOST_TLS_CERT = `-----BEGIN CERTIFICATE----- +MIICpDCCAYwCCQCRYoVtQ3nVXjANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls +b2NhbGhvc3QwHhcNMjYwNDI5MDA0ODEzWhcNMjYwNDMwMDA0ODEzWjAUMRIwEAYD +VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM +xR6FpXcONRoz9XgftbFKMJCuoGsa0eIooASG/PIUzkl08UhqM+60UgjwQX/HnA/E +lXqBamnmXyK0drO6t+RoatX4YCkJ4E0EVjOQgoZe1R6/tld1MT69OUkndPm0D4am +TVYrW8xuBQk/y3DOiBNVkRc4VaMgdHd3VnnFH+HZFHdADyh3kchQ/vxj4MarCTcO +NZIjzrkqoK1yc6Hi2gCFN3gGypVVerNMKh24qYRwqVknKiMzRnCudqwQeeFysyHw +YMujVw7/PAJSfV9s1G3Ui3bQmQcGivFELC2SK7dGcr5tK5MBioVLbVkHN8hAwUSZ +iOJAAEKItzPlcpGoPSOJAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAIG+juslOvfW +m3FxmsTuTS+ZCu+RLZPslXHvnHRNXbVmGcRfGdXJQNnhZyXkB9sfrlD5DRn5dwiE +4g5oH7qcdSZ86EsBU9zePFD1IBZa1jby3VAf+rGmwL8jjHjQp8Oc2++jdC/ecft+ +EoX6Fj+g2v4F2cWn3YRBKYb2h4Fp02ejKktypRv1JUp3m6D2ar5591MKjBahYW8n +ULIQMxStpMK1b0GZa2WKhXhRNnNa21ehyfWNBQw9QI1OjedJItLXfOZBzXrT9WAC +RcYwHeuM43ak33DUO93y/AnaSQRxpdLNRW7q5L8uX5ly1XR9u5aPjq71yUj8cHKr +moxci9K2uno= +-----END CERTIFICATE-----`; diff --git a/impit-node/vanilla-fallback.js b/impit-node/vanilla-fallback.js new file mode 100644 index 00000000..1d9c126e --- /dev/null +++ b/impit-node/vanilla-fallback.js @@ -0,0 +1,539 @@ +const http = require('node:http'); +const https = require('node:https'); +const { Readable } = require('node:stream'); +const tls = require('node:tls'); +const zlib = require('node:zlib'); + +function getSetCookieValues(headers) { + if (!headers) { + return []; + } + + if (typeof headers.getSetCookie === 'function') { + return [...headers.getSetCookie()]; + } + + const nodeSetCookie = headers['set-cookie']; + if (Array.isArray(nodeSetCookie)) { + return [...nodeSetCookie]; + } + + if (typeof nodeSetCookie === 'string') { + return [nodeSetCookie]; + } + + if (typeof headers.get === 'function') { + const combined = headers.get('set-cookie'); + return combined ? [combined] : []; + } + + return []; +} + +function cloneHeadersWithSetCookie(headers) { + const cloned = new Headers(headers); + const setCookies = getSetCookieValues(headers); + if (setCookies.length > 0) { + Object.defineProperty(cloned, 'getSetCookie', { + value: () => [...setCookies], + configurable: true, + }); + } + + return cloned; +} + +const VANILLA_FALLBACK_ERROR_CODES = new Set([ + 'ConnectTimeout', + 'PoolTimeout', + 'ConnectError', + 'ProxyError', + 'ProxyTunnelError', + 'ProxyAuthRequired', + 'UnsupportedProtocol', +]); + +const VANILLA_FALLBACK_MESSAGE_PATTERNS = [ + /sendrequest/i, + /hyper_util::client::legacy::Error\(\s*Connect/i, + /unexpectedmessage/i, + /SelectedUnusableCipherSuiteForVersion/i, + /UnexpectedEof/i, + /Failed to connect to the server/i, + /Proxy CONNECT failed/i, + /Proxy authentication required/i, +]; + +const VANILLA_FALLBACK_RESET_PATTERNS = [ + /connection reset by peer/i, + /connection reset/i, + /socket hang up/i, +]; + +const COMPATIBILITY_HEADER_ORDER = [ + 'Host', + 'Connection', + 'Content-Length', + 'Upgrade-Insecure-Requests', + 'User-Agent', + 'Accept', + 'Sec-Fetch-Site', + 'Sec-Fetch-Mode', + 'Sec-Fetch-User', + 'Sec-Fetch-Dest', + 'Accept-Encoding', + 'Accept-Language', + 'Sec-Ch-Ua', + 'Sec-Ch-Ua-Mobile', + 'Sec-Ch-Ua-Platform', + 'Priority', + 'Cookie', + 'Origin', + 'Referer', + 'Content-Type', +]; + +const COMPATIBILITY_BROWSER_HEADERS = { + chrome124: [ + ['Sec-Ch-Ua', '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"'], + ['Sec-Ch-Ua-Mobile', '?0'], + ['Sec-Ch-Ua-Platform', '"macOS"'], + ['Upgrade-Insecure-Requests', '1'], + ['User-Agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'], + ['Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'], + ['Sec-Fetch-Site', 'none'], + ['Sec-Fetch-Mode', 'navigate'], + ['Sec-Fetch-User', '?1'], + ['Sec-Fetch-Dest', 'document'], + ['Accept-Encoding', 'gzip, deflate, br'], + ['Accept-Language', 'en-US,en;q=0.9'], + ['Priority', 'u=0, i'], + ], +}; + +function getNativeErrorCode(error) { + const match = error?.message?.match(/^(\w+): /); + return match?.[1]; +} + +function shouldRetryWithVanillaFallback(error) { + const code = getNativeErrorCode(error); + if (code && VANILLA_FALLBACK_ERROR_CODES.has(code)) { + return true; + } + + const message = error?.message ?? ''; + if (VANILLA_FALLBACK_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) { + return true; + } + + return VANILLA_FALLBACK_RESET_PATTERNS.some((pattern) => pattern.test(message)) + && /sendrequest|kind:\s*request|hyper_util::client::legacy::Error\(\s*Connect/i.test(message); +} + +function toCompatibilityHeaderCase(header) { + if (header.toLowerCase().startsWith('x-')) { + return header; + } + + return header + .split('-') + .map((part) => part ? part[0].toUpperCase() + part.slice(1).toLowerCase() : part) + .join('-'); +} + +function orderCompatibilityHeaders(headers) { + const orderIndex = new Map(COMPATIBILITY_HEADER_ORDER.map((name, index) => [name, index])); + + return headers.sort(([left], [right]) => { + const leftIndex = orderIndex.has(left) ? orderIndex.get(left) : Number.MAX_SAFE_INTEGER; + const rightIndex = orderIndex.has(right) ? orderIndex.get(right) : Number.MAX_SAFE_INTEGER; + if (leftIndex !== rightIndex) { + return leftIndex - rightIndex; + } + + return left.localeCompare(right); + }); +} + +function getCompatibilityBrowserProfile(browser) { + switch (browser) { + case 'chrome': + case 'chrome124': + return 'chrome124'; + default: + return null; + } +} + +function createCompatibilityDecodeStream(contentEncoding, responseStream) { + const encodings = String(contentEncoding ?? '') + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + + if (encodings.length === 0) { + return { + decoded: false, + stream: responseStream, + }; + } + + const transforms = []; + for (const encoding of [...encodings].reverse()) { + if (encoding === 'gzip' || encoding === 'x-gzip') { + transforms.push(zlib.createGunzip()); + continue; + } + + if (encoding === 'br') { + transforms.push(zlib.createBrotliDecompress()); + continue; + } + + if (encoding === 'deflate') { + transforms.push(zlib.createInflate()); + continue; + } + + return { + decoded: false, + stream: responseStream, + }; + } + + const decodedStream = transforms.reduce( + (stream, transform) => stream.pipe(transform), + responseStream, + ); + + return { + decoded: true, + stream: decodedStream, + }; +} + +class VanillaFallbackController { + #compatibilityProfile; + #proxyUrl; + #http3Enabled; + #localAddress; + #ignoreTlsErrors; + + constructor(options) { + this.#compatibilityProfile = getCompatibilityBrowserProfile(options?.browser); + this.#proxyUrl = options?.proxyUrl; + this.#http3Enabled = Boolean(options?.http3); + this.#localAddress = options?.localAddress; + this.#ignoreTlsErrors = Boolean(options?.ignoreTlsErrors); + } + + shouldRetry(error) { + return shouldRetryWithVanillaFallback(error); + } + + canUseCompatibilityTransport(url) { + if (!this.#compatibilityProfile || this.#http3Enabled || url.protocol !== 'https:') { + return false; + } + + if (!this.#proxyUrl) { + return true; + } + + try { + const proxyProtocol = new URL(this.#proxyUrl).protocol; + return proxyProtocol === 'http:' || proxyProtocol === 'https:'; + } catch { + return false; + } + } + + #buildCompatibilityHeaders(url, headers, body) { + const normalizedMap = new Map(); + const pushHeader = (key, value) => { + const headerName = toCompatibilityHeaderCase(key); + normalizedMap.set(headerName.toLowerCase(), [headerName, value]); + }; + + for (const [key, value] of COMPATIBILITY_BROWSER_HEADERS[this.#compatibilityProfile] ?? []) { + pushHeader(key, value); + } + + for (const [key, value] of headers ?? []) { + pushHeader(key, value); + } + + if (!normalizedMap.has('host')) { + normalizedMap.set('host', ['Host', url.host]); + } + + if (!normalizedMap.has('connection')) { + normalizedMap.set('connection', ['Connection', 'keep-alive']); + } + + if (!normalizedMap.has('content-length') && !normalizedMap.has('transfer-encoding')) { + const bodyLength = body ? body.length : 0; + normalizedMap.set('content-length', ['Content-Length', String(bodyLength)]); + } + + return orderCompatibilityHeaders([...normalizedMap.values()]); + } + + async #createProxyTunnel(url, options = {}) { + const proxy = new URL(this.#proxyUrl); + const proxyPort = Number(proxy.port || (proxy.protocol === 'https:' ? 443 : 80)); + const targetHost = `${url.hostname}:${url.port || 443}`; + const headers = { host: targetHost }; + const basicAuth = proxy.username + ? `Basic ${Buffer.from( + `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`, + ).toString('base64')}` + : null; + if (basicAuth) { + headers['proxy-authorization'] = basicAuth; + } + + const requestFactory = proxy.protocol === 'https:' ? https.request : http.request; + + const tunnelSocket = await new Promise((resolve, reject) => { + let upstreamSocket = null; + const cleanup = () => { + options.signal?.removeEventListener?.('abort', onAbort); + }; + const onAbort = () => { + const reason = options.signal?.reason ?? new Error('The operation was aborted'); + connectRequest.destroy(reason); + upstreamSocket?.destroy(reason); + }; + const connectRequest = requestFactory({ + host: proxy.hostname, + port: proxyPort, + localAddress: this.#localAddress, + method: 'CONNECT', + path: targetHost, + headers, + agent: false, + rejectUnauthorized: true, + }); + if (options.timeout) { + connectRequest.setTimeout(options.timeout, () => { + connectRequest.destroy(new Error(`Proxy CONNECT timed out after ${options.timeout}ms`)); + }); + } + if (options.signal) { + if (options.signal.aborted) { + onAbort(); + return; + } + options.signal.addEventListener('abort', onAbort, { once: true }); + } + + connectRequest.once('connect', (response, socket, head) => { + upstreamSocket = socket; + if (response.statusCode !== 200 || head.length > 0) { + cleanup(); + socket.destroy(); + reject(new Error(`Proxy CONNECT failed with status ${response.statusCode || 'unknown'}`)); + return; + } + + const secureSocket = tls.connect({ + socket, + servername: url.hostname, + rejectUnauthorized: !this.#ignoreTlsErrors, + }, () => { + if (options.signal?.aborted) { + const reason = options.signal.reason ?? new Error('The operation was aborted'); + cleanup(); + secureSocket.destroy(reason); + reject(reason); + return; + } + cleanup(); + resolve(secureSocket); + }); + secureSocket.once('error', (error) => { + cleanup(); + reject(error); + }); + }); + + connectRequest.once('error', (error) => { + cleanup(); + reject(error); + }); + connectRequest.end(); + }); + + return tunnelSocket; + } + + async fetchWithCompatibilityTransport(rawUrl, options) { + const url = new URL(rawUrl); + const isHttps = url.protocol === 'https:'; + const body = options.body ? Buffer.from(options.body) : null; + const orderedHeaders = this.#buildCompatibilityHeaders(url, options.headers, body); + const directHttpsAgent = !this.#proxyUrl && isHttps + ? new https.Agent({ + keepAlive: true, + scheduling: 'lifo', + timeout: 5_000, + noDelay: true, + }) + : null; + let activeSocket = null; + let request = null; + + const requestOptions = { + protocol: url.protocol, + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + localAddress: this.#localAddress, + path: `${url.pathname}${url.search}`, + method: options.method || 'GET', + rejectUnauthorized: !this.#ignoreTlsErrors, + agent: directHttpsAgent ?? undefined, + createConnection: this.#proxyUrl && isHttps + ? (_connectOptions, callback) => { + this.#createProxyTunnel(url, { + signal: options.signal, + timeout: options.timeout, + }).then( + (socket) => { + if (options.signal?.aborted) { + const reason = options.signal.reason ?? new Error('The operation was aborted'); + socket.destroy(reason); + callback(reason); + return; + } + activeSocket = socket; + callback(null, socket); + }, + (error) => callback(error), + ); + } + : undefined, + }; + + const transport = isHttps ? https : http; + + return new Promise((resolve, reject) => { + const destroyDirectAgent = () => { + directHttpsAgent?.destroy(); + }; + const cleanup = () => { + options.signal?.removeEventListener?.('abort', onAbort); + }; + const onAbort = () => { + const reason = options.signal?.reason ?? new Error('The operation was aborted'); + request?.destroy(reason); + activeSocket?.destroy(reason); + destroyDirectAgent(); + }; + + request = transport.request(requestOptions, (response) => { + activeSocket = response.socket ?? activeSocket; + const { decoded, stream } = createCompatibilityDecodeStream( + response.headers['content-encoding'], + response, + ); + stream.once('close', destroyDirectAgent); + stream.once('error', (error) => { + cleanup(); + destroyDirectAgent(); + reject(error); + }); + response.once('error', (error) => { + cleanup(); + destroyDirectAgent(); + reject(error); + }); + + const compatibilityHeaders = cloneHeadersWithSetCookie(response.headers); + if (decoded) { + compatibilityHeaders.delete('content-encoding'); + compatibilityHeaders.delete('content-length'); + } + + const compatibilityResponse = new Response(Readable.toWeb(stream), { + status: response.statusCode, + statusText: response.statusMessage, + headers: compatibilityHeaders, + }); + + const arrayBuffer = compatibilityResponse.arrayBuffer.bind(compatibilityResponse); + Object.defineProperty(compatibilityResponse, 'bytes', { + value: async () => new Uint8Array(await arrayBuffer()), + configurable: true, + }); + Object.defineProperty(compatibilityResponse, 'decodeBuffer', { + value: (buffer) => Buffer.from(buffer).toString('utf8'), + configurable: true, + }); + Object.defineProperty(compatibilityResponse, 'abort', { + value: () => { + request.destroy(); + activeSocket?.destroy(); + response.destroy(); + stream.destroy?.(); + destroyDirectAgent(); + }, + configurable: true, + }); + Object.defineProperty(compatibilityResponse, 'url', { + value: rawUrl, + enumerable: true, + configurable: true, + }); + Object.defineProperty(compatibilityResponse, '__impitCompat', { + value: true, + configurable: true, + }); + + cleanup(); + resolve(compatibilityResponse); + }); + + if (options.timeout) { + request.setTimeout(options.timeout, () => { + request.destroy(new Error(`Compatibility transport timed out after ${options.timeout}ms`)); + }); + } + if (options.signal) { + if (options.signal.aborted) { + onAbort(); + return; + } + options.signal.addEventListener('abort', onAbort, { once: true }); + } + + request.on('socket', (socket) => { + activeSocket = socket; + if (options.signal?.aborted) { + socket.destroy(options.signal.reason ?? new Error('The operation was aborted')); + } + }); + request.on('error', (error) => { + cleanup(); + destroyDirectAgent(); + reject(error); + }); + + for (const [key, value] of orderedHeaders) { + request.setHeader(key, value); + } + + if (body) { + request.end(body); + } else { + request.end(''); + } + }); + } +} + +module.exports = { + VanillaFallbackController, + cloneHeadersWithSetCookie, +};