diff --git a/.github/scripts/before-beta-release.js b/.github/scripts/before-beta-release.cjs similarity index 100% rename from .github/scripts/before-beta-release.js rename to .github/scripts/before-beta-release.cjs diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index d35e6327..fdd1879d 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -3,38 +3,38 @@ name: Check on: - pull_request: - workflow_call: + pull_request: + workflow_call: jobs: - lint_and_test: - name: Lint & Test - if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} - runs-on: ubuntu-22.04 + lint_and_test: + name: Lint & Test + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} + runs-on: ubuntu-22.04 - strategy: - matrix: - node-version: [14, 16, 18] + strategy: + matrix: + node-version: [20, 22, 24] - steps: - - name: Checkout repository - uses: actions/checkout@v6 + steps: + - name: Checkout repository + uses: actions/checkout@v6 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} - - name: Install Dependencies - run: npm install + - name: Install Dependencies + run: npm install - # Lint only on the latest Node.js version to save time. - - name: Lint code - if: ${{ matrix.node-version == 18 }} - run: npm run lint + # Lint only on the latest Node.js version to save time. + - name: Lint code + if: ${{ matrix.node-version == 20 }} + run: npm run lint - - name: Add localhost-test to Linux hosts file - run: sudo echo "127.0.0.1 localhost-test" | sudo tee -a /etc/hosts + - name: Add localhost-test to Linux hosts file + run: sudo echo "127.0.0.1 localhost-test" | sudo tee -a /etc/hosts - - name: Run Tests - run: npm test + - name: Run Tests + run: npm test diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 434bb4f5..46046219 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,58 +1,58 @@ name: Check & Release on: - # Push to master will deploy a beta version - push: - branches: - - master - # A release via GitHub releases will deploy a latest version - release: - types: [published] + # Push to master will deploy a beta version + push: + branches: + - master + # A release via GitHub releases will deploy a latest version + release: + types: [published] # Necessary permissions for publishing to NPM with OIDC permissions: - contents: write - id-token: write + contents: write + id-token: write jobs: - lint_and_test: - name: Lint and test - uses: ./.github/workflows/check.yaml - - deploy: - name: Publish to NPM - needs: [lint_and_test] - runs-on: ubuntu-22.04 - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Use Node.js 24 - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Install dependencies - run: npm install - - # Determine if this is a beta or latest release - - name: Get release tag - id: get_release_tag - run: echo "release_tag=$(if [ ${{ github.event_name }} = release ]; then echo latest; else echo beta; fi)" >> $GITHUB_OUTPUT - - # Check version consistency and increment pre-release version number for beta only. - - name: Bump pre-release version - if: steps.get_release_tag.outputs.release_tag == 'beta' - run: node ./.github/scripts/before-beta-release.js - - - name: Publish to NPM - run: npm publish --tag ${{ steps.get_release_tag.outputs.release_tag }} - - # Latest version is tagged by the release process so we only tag beta here. - - name: Tag version - if: steps.get_release_tag.outputs.release_tag == 'beta' - run: | - git_tag=v`node -p "require('./package.json').version"` - git tag $git_tag - git push origin $git_tag + lint_and_test: + name: Lint and test + uses: ./.github/workflows/check.yaml + + deploy: + name: Publish to NPM + needs: [lint_and_test] + runs-on: ubuntu-22.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Use Node.js 24 + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install dependencies + run: npm install + + # Determine if this is a beta or latest release + - name: Get release tag + id: get_release_tag + run: echo "release_tag=$(if [ ${{ github.event_name }} = release ]; then echo latest; else echo beta; fi)" >> $GITHUB_OUTPUT + + # Check version consistency and increment pre-release version number for beta only. + - name: Bump pre-release version + if: steps.get_release_tag.outputs.release_tag == 'beta' + run: node ./.github/scripts/before-beta-release.cjs + + - name: Publish to NPM + run: npm publish --tag ${{ steps.get_release_tag.outputs.release_tag }} + + # Latest version is tagged by the release process so we only tag beta here. + - name: Tag version + if: steps.get_release_tag.outputs.release_tag == 'beta' + run: | + git_tag=v`node -p "require('./package.json').version"` + git tag $git_tag + git push origin $git_tag diff --git a/.mocharc.json b/.mocharc.json index d22cea1b..2c63c085 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -1,3 +1,2 @@ { - "require": "ts-node/register" } diff --git a/README.md b/README.md index cb682c1b..1696d544 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The proxy-chain package currently supports HTTP/SOCKS forwarding and HTTP CONNEC ## Run a simple HTTP/HTTPS proxy server ```javascript -const ProxyChain = require('proxy-chain'); +import ProxyChain from 'proxy-chain'; const server = new ProxyChain.Server({ port: 8000 }); @@ -33,7 +33,7 @@ server.listen(() => { ## Run a HTTP/HTTPS proxy server with credentials and upstream proxy ```javascript -const ProxyChain = require('proxy-chain'); +import ProxyChain from 'proxy-chain'; const server = new ProxyChain.Server({ // Port where the server will listen. By default 8000. @@ -115,9 +115,12 @@ server.on('requestFailed', ({ request, error }) => { This example demonstrates how to create an HTTPS proxy server with a self-signed certificate. The HTTPS proxy server works identically to the HTTP version but with TLS encryption. ```javascript -const fs = require('fs'); -const path = require('path'); -const ProxyChain = require('proxy-chain'); +import ProxyChain from 'proxy-chain'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); (async () => { // TODO: update these lines to use your own key and cert @@ -208,9 +211,9 @@ curl --proxy-insecure -x https://localhost:8443 -k https://example.com You can provide custom HTTP/HTTPS agents to enable connection pooling and reuse with upstream proxies. This is particularly useful for maintaining sticky IP addresses or reducing connection overhead: ```javascript -const http = require('http'); -const https = require('https'); -const ProxyChain = require('proxy-chain'); +import http from 'node:http'; +import https from 'node:https'; +import ProxyChain from 'proxy-chain'; // Create agents with keepAlive to enable connection pooling const httpAgent = new http.Agent({ @@ -309,7 +312,7 @@ The class constructor has the following parameters: `RequestError(body, statusCo By default, the response will have `Content-Type: text/plain; charset=utf-8`. ```javascript -const ProxyChain = require('proxy-chain'); +import ProxyChain from 'proxy-chain'; const server = new ProxyChain.Server({ prepareRequestFunction: ({ request, username, password, hostname, port, isHttp, connectionId }) => { @@ -379,7 +382,7 @@ with the following properties: Here is a simple example: ```javascript -const ProxyChain = require('proxy-chain'); +import ProxyChain from 'proxy-chain'; const server = new ProxyChain.Server({ port: 8000, @@ -406,8 +409,8 @@ While `customResponseFunction` enables custom handling methods such as `GET` and It's possible to route those requests differently using the `customConnectServer` option. It accepts an instance of Node.js HTTP server. ```javascript -const http = require('http'); -const ProxyChain = require('proxy-chain'); +import http from 'node:http'; +import ProxyChain from 'proxy-chain'; const exampleServer = http.createServer((request, response) => { response.end('Hello from a custom server!'); @@ -437,8 +440,9 @@ This is an unsecure server, so it accepts only `http:` requests. In order to intercept `https:` requests, `https.createServer` should be used instead, along with a self signed certificate. ```javascript -const https = require('https'); -const fs = require('fs'); +import https from 'node:https'; +import fs from 'node:fs'; + const key = fs.readFileSync('./test/ssl.key'); const cert = fs.readFileSync('./test/ssl.crt'); @@ -522,8 +526,8 @@ from headless Chrome and [Puppeteer](https://github.com/GoogleChrome/puppeteer). For details, read this [blog post](https://blog.apify.com/how-to-make-headless-chrome-and-puppeteer-use-a-proxy-server-with-authentication-249a21a79212). ```javascript -const puppeteer = require('puppeteer'); -const proxyChain = require('proxy-chain'); +import puppeteer from 'puppeteer'; +import proxyChain from 'proxy-chain'; (async() => { const oldProxyUrl = 'http://bob:password123@proxy.example.com:8000'; diff --git a/examples/apify_proxy_tunnel.js b/examples/apify_proxy_tunnel.js index f06556ae..b5bbf870 100644 --- a/examples/apify_proxy_tunnel.js +++ b/examples/apify_proxy_tunnel.js @@ -1,4 +1,4 @@ -const { createTunnel, closeTunnel, redactUrl } = require('proxy-chain'); +import { closeTunnel, createTunnel, redactUrl } from 'proxy-chain'; // This example demonstrates how to create a tunnel via Apify's HTTP proxy service. // For details, see https://blog.apify.com/tunneling-arbitrary-protocols-over-http-proxy-with-static-ip-address-b3a2222191ff diff --git a/package.json b/package.json index 3480470c..2359cf30 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,13 @@ "version": "2.7.0", "description": "Node.js implementation of a proxy server (think Squid) with support for SSL, authentication, upstream proxy chaining, and protocol tunneling.", "main": "dist/index.js", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, "keywords": [ "proxy", "squid", @@ -44,13 +51,13 @@ "lint:fix": "eslint . --fix" }, "engines": { - "node": ">=14" + "node": ">=20" }, "devDependencies": { "@apify/eslint-config": "^1.0.0", "@apify/tsconfig": "^0.1.0", "@types/jest": "^28.1.2", - "@types/node": "^18.8.3", + "@types/node": "^20.19.29", "basic-auth": "^2.0.1", "basic-auth-parser": "^0.0.2", "body-parser": "^1.19.0", @@ -72,8 +79,8 @@ "sinon-stub-promise": "^4.0.0", "socksv5": "^0.0.6", "through": "^2.3.8", - "ts-node": "^10.2.1", - "typescript": "^4.4.3", + "ts-node": "^10.9.2", + "typescript": "^5.9.3", "typescript-eslint": "^8.20.0", "underscore": "^1.13.1", "ws": "^8.2.2" diff --git a/scripts/test-docker-all.sh b/scripts/test-docker-all.sh index f74721ca..2059d5cb 100755 --- a/scripts/test-docker-all.sh +++ b/scripts/test-docker-all.sh @@ -1,29 +1,29 @@ #!/bin/bash -echo "Starting parallel Docker tests for Node 14, 16, and 18..." +echo "Starting parallel Docker tests for Node 20, 22, and 24..." # Run builds in parallel, capture PIDs. -docker build --build-arg NODE_IMAGE=node:14.21.3-bullseye --tag proxy-chain-tests:node14 --file test/Dockerfile . && docker run --add-host localhost-test:127.0.0.1 proxy-chain-tests:node14 & -pid14=$! -docker build --build-arg NODE_IMAGE=node:16.20.2-bookworm --tag proxy-chain-tests:node16 --file test/Dockerfile . && docker run --add-host localhost-test:127.0.0.1 proxy-chain-tests:node16 & -pid16=$! -docker build --build-arg NODE_IMAGE=node:18.20.8-bookworm --tag proxy-chain-tests:node18 --file test/Dockerfile . && docker run --add-host localhost-test:127.0.0.1 proxy-chain-tests:node18 & -pid18=$! +docker build --build-arg NODE_IMAGE=node:20.19.6-bookworm --tag proxy-chain-tests:node20 --file test/Dockerfile . && docker run --add-host localhost-test:127.0.0.1 proxy-chain-tests:node20 & +pid20=$! +docker build --build-arg NODE_IMAGE=node:22.21.1-bookworm --tag proxy-chain-tests:node22 --file test/Dockerfile . && docker run --add-host localhost-test:127.0.0.1 proxy-chain-tests:node22 & +pid22=$! +docker build --build-arg NODE_IMAGE=node:24.12.0-bookworm --tag proxy-chain-tests:node24 --file test/Dockerfile . && docker run --add-host localhost-test:127.0.0.1 proxy-chain-tests:node24 & +pid24=$! # Wait for all and capture exit codes. -wait $pid14 -ec14=$? -wait $pid16 -ec16=$? -wait $pid18 -ec18=$? +wait $pid20 +ec20=$? +wait $pid22 +ec22=$? +wait $pid24 +ec24=$? echo "" echo "========== Results ==========" -echo "Node 14: $([ $ec14 -eq 0 ] && echo 'PASS' || echo 'FAIL')" -echo "Node 16: $([ $ec16 -eq 0 ] && echo 'PASS' || echo 'FAIL')" -echo "Node 18: $([ $ec18 -eq 0 ] && echo 'PASS' || echo 'FAIL')" +echo "Node 20: $([ $ec20 -eq 0 ] && echo 'PASS' || echo 'FAIL')" +echo "Node 22: $([ $ec22 -eq 0 ] && echo 'PASS' || echo 'FAIL')" +echo "Node 24: $([ $ec24 -eq 0 ] && echo 'PASS' || echo 'FAIL')" echo "=============================" # Exit with non-zero if any failed. -exit $((ec14 + ec16 + ec18)) +exit $((ec20 + ec22 + ec24)) diff --git a/src/anonymize_proxy.ts b/src/anonymize_proxy.ts index 9b7cbd8e..33fe190b 100644 --- a/src/anonymize_proxy.ts +++ b/src/anonymize_proxy.ts @@ -3,8 +3,7 @@ import type http from 'node:http'; import type net from 'node:net'; import { URL } from 'node:url'; -import { Server, SOCKS_PROTOCOLS } from './server'; -import { nodeify } from './utils/nodeify'; +import { Server, SOCKS_PROTOCOLS } from './server.js'; // Dictionary, key is value returned from anonymizeProxy(), value is Server instance. const anonymizedProxyUrlToServer: Record = {}; @@ -22,7 +21,6 @@ export interface AnonymizeProxyOptions { */ export const anonymizeProxy = async ( options: string | AnonymizeProxyOptions, - callback?: (error: Error | null) => void, ): Promise => { let proxyUrl: string; let port = 0; @@ -52,7 +50,7 @@ export const anonymizeProxy = async ( // If upstream proxy requires no password or if there is no need to ignore HTTPS proxy cert errors, return it directly if (!parsedProxyUrl.username && !parsedProxyUrl.password && (!ignoreProxyCertificate || parsedProxyUrl.protocol !== 'https:')) { - return nodeify(Promise.resolve(proxyUrl), callback); + return proxyUrl; } let server: Server & { port: number }; @@ -82,7 +80,7 @@ export const anonymizeProxy = async ( return url; }); - return nodeify(promise, callback); + return promise; }; /** @@ -94,7 +92,6 @@ export const anonymizeProxy = async ( export const closeAnonymizedProxy = async ( anonymizedProxyUrl: string, closeConnections: boolean, - callback?: (error: Error | null, result?: boolean) => void, ): Promise => { if (typeof anonymizedProxyUrl !== 'string') { throw new Error('The "anonymizedProxyUrl" parameter must be a string'); @@ -102,7 +99,7 @@ export const closeAnonymizedProxy = async ( const server = anonymizedProxyUrlToServer[anonymizedProxyUrl]; if (!server) { - return nodeify(Promise.resolve(false), callback); + return false; } delete anonymizedProxyUrlToServer[anonymizedProxyUrl]; @@ -110,7 +107,7 @@ export const closeAnonymizedProxy = async ( const promise = server.close(closeConnections).then(() => { return true; }); - return nodeify(promise, callback); + return promise; }; type Callback = ({ diff --git a/src/chain.ts b/src/chain.ts index 1cf64b58..5e02bfd0 100644 --- a/src/chain.ts +++ b/src/chain.ts @@ -5,11 +5,11 @@ import http from 'node:http'; import https from 'node:https'; import type { URL } from 'node:url'; -import type { Socket } from './socket'; -import { badGatewayStatusCodes, createCustomStatusHttpResponse, errorCodeToStatusCode } from './statuses'; -import type { SocketWithPreviousStats } from './utils/count_target_bytes'; -import { countTargetBytes } from './utils/count_target_bytes'; -import { getBasicAuthorizationHeader } from './utils/get_basic'; +import type { Socket } from './socket.js'; +import { badGatewayStatusCodes, createCustomStatusHttpResponse, errorCodeToStatusCode } from './statuses.js'; +import type { SocketWithPreviousStats } from './utils/count_target_bytes.js'; +import { countTargetBytes } from './utils/count_target_bytes.js'; +import { getBasicAuthorizationHeader } from './utils/get_basic.js'; interface Options { method: string; diff --git a/src/chain_socks.ts b/src/chain_socks.ts index 1b03c70c..2b874835 100644 --- a/src/chain_socks.ts +++ b/src/chain_socks.ts @@ -6,9 +6,9 @@ import { URL } from 'node:url'; import { SocksClient, type SocksClientError, type SocksProxy } from 'socks'; -import type { Socket } from './socket'; -import { createCustomStatusHttpResponse, socksErrorMessageToStatusCode } from './statuses'; -import { countTargetBytes } from './utils/count_target_bytes'; +import type { Socket } from './socket.js'; +import { createCustomStatusHttpResponse, socksErrorMessageToStatusCode } from './statuses.js'; +import { countTargetBytes } from './utils/count_target_bytes.js'; export interface HandlerOpts { upstreamProxyUrlParsed: URL; diff --git a/src/direct.ts b/src/direct.ts index f4c7d68d..b81335dd 100644 --- a/src/direct.ts +++ b/src/direct.ts @@ -4,8 +4,8 @@ import type { EventEmitter } from 'node:events'; import net from 'node:net'; import { URL } from 'node:url'; -import type { Socket } from './socket'; -import { countTargetBytes } from './utils/count_target_bytes'; +import type { Socket } from './socket.js'; +import { countTargetBytes } from './utils/count_target_bytes.js'; export interface HandlerOpts { localAddress?: string; diff --git a/src/forward.ts b/src/forward.ts index 2c9b6ab9..1292b04c 100644 --- a/src/forward.ts +++ b/src/forward.ts @@ -5,11 +5,11 @@ import stream from 'node:stream'; import type { URL } from 'node:url'; import util from 'node:util'; -import { badGatewayStatusCodes, errorCodeToStatusCode } from './statuses'; -import type { SocketWithPreviousStats } from './utils/count_target_bytes'; -import { countTargetBytes } from './utils/count_target_bytes'; -import { getBasicAuthorizationHeader } from './utils/get_basic'; -import { validHeadersOnly } from './utils/valid_headers_only'; +import { badGatewayStatusCodes, errorCodeToStatusCode } from './statuses.js'; +import type { SocketWithPreviousStats } from './utils/count_target_bytes.js'; +import { countTargetBytes } from './utils/count_target_bytes.js'; +import { getBasicAuthorizationHeader } from './utils/get_basic.js'; +import { validHeadersOnly } from './utils/valid_headers_only.js'; const pipeline = util.promisify(stream.pipeline); diff --git a/src/forward_socks.ts b/src/forward_socks.ts index 95867586..71f9a74b 100644 --- a/src/forward_socks.ts +++ b/src/forward_socks.ts @@ -5,9 +5,9 @@ import util from 'node:util'; import { SocksProxyAgent } from 'socks-proxy-agent'; -import { badGatewayStatusCodes, errorCodeToStatusCode } from './statuses'; -import { countTargetBytes } from './utils/count_target_bytes'; -import { validHeadersOnly } from './utils/valid_headers_only'; +import { badGatewayStatusCodes, errorCodeToStatusCode } from './statuses.js'; +import { countTargetBytes } from './utils/count_target_bytes.js'; +import { validHeadersOnly } from './utils/valid_headers_only.js'; const pipeline = util.promisify(stream.pipeline); diff --git a/src/index.ts b/src/index.ts index f945ef87..54867287 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ -export * from './request_error'; -export * from './server'; -export * from './utils/redact_url'; -export * from './anonymize_proxy'; -export * from './tcp_tunnel_tools'; +export * from './request_error.js'; +export * from './server.js'; +export * from './utils/redact_url.js'; +export * from './anonymize_proxy.js'; +export * from './tcp_tunnel_tools.js'; -export { CustomResponse } from './custom_response'; +export type { CustomResponse } from './custom_response.js'; diff --git a/src/server.ts b/src/server.ts index 6295c724..c7898653 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,24 +8,23 @@ import type net from 'node:net'; import { URL } from 'node:url'; import util from 'node:util'; -import type { HandlerOpts as ChainOpts } from './chain'; -import { chain } from './chain'; -import { chainSocks } from './chain_socks'; -import { customConnect } from './custom_connect'; -import type { HandlerOpts as CustomResponseOpts } from './custom_response'; -import { handleCustomResponse } from './custom_response'; -import { direct } from './direct'; -import type { HandlerOpts as ForwardOpts } from './forward'; -import { forward } from './forward'; -import { forwardSocks } from './forward_socks'; -import { RequestError } from './request_error'; -import type { Socket, TLSSocket } from './socket'; -import { badGatewayStatusCodes } from './statuses'; -import { getTargetStats } from './utils/count_target_bytes'; -import { nodeify } from './utils/nodeify'; -import { normalizeUrlPort } from './utils/normalize_url_port'; -import { parseAuthorizationHeader } from './utils/parse_authorization_header'; -import { redactUrl } from './utils/redact_url'; +import type { HandlerOpts as ChainOpts } from './chain.js'; +import { chain } from './chain.js'; +import { chainSocks } from './chain_socks.js'; +import { customConnect } from './custom_connect.js'; +import type { HandlerOpts as CustomResponseOpts } from './custom_response.js'; +import { handleCustomResponse } from './custom_response.js'; +import { direct } from './direct.js'; +import type { HandlerOpts as ForwardOpts } from './forward.js'; +import { forward } from './forward.js'; +import { forwardSocks } from './forward_socks.js'; +import { RequestError } from './request_error.js'; +import type { Socket, TLSSocket } from './socket.js'; +import { badGatewayStatusCodes } from './statuses.js'; +import { getTargetStats } from './utils/count_target_bytes.js'; +import { normalizeUrlPort } from './utils/normalize_url_port.js'; +import { parseAuthorizationHeader } from './utils/parse_authorization_header.js'; +import { redactUrl } from './utils/redact_url.js'; export const SOCKS_PROTOCOLS = ['socks:', 'socks4:', 'socks4a:', 'socks5:', 'socks5h:']; @@ -45,7 +44,7 @@ const HTTPS_DEFAULT_OPTIONS = { // Disable TLS 1.0 and 1.1 (deprecated, insecure). // All other TLS settings use Node.js defaults for cipher selection (automatically updated). minVersion: 'TLSv1.2', -} as const; +} as const satisfies Partial; /** * Connection statistics for bandwidth tracking. @@ -666,12 +665,10 @@ export class Server extends EventEmitter { /** * Starts listening at a port specified in the constructor. */ - async listen(callback?: (error: NodeJS.ErrnoException | null) => void): Promise { - const promise = new Promise((resolve, reject) => { - // Unfortunately server.listen() is not a normal function that fails on error, - // so we need this trickery + async listen(): Promise { + return new Promise((resolve, reject) => { const onError = (error: NodeJS.ErrnoException) => { - this.log(null, `Listen failed: ${error}`); + this.log(null, `Listen error: ${error}`); removeListeners(); reject(error); }; @@ -690,8 +687,6 @@ export class Server extends EventEmitter { this.server.on('listening', onListening); this.server.listen(this.port, this.host); }); - - return nodeify(promise, callback); } /** @@ -751,12 +746,7 @@ export class Server extends EventEmitter { * Closes the proxy server. * @param closeConnections If true, pending proxy connections are forcibly closed. */ - async close(closeConnections: boolean, callback?: (error: NodeJS.ErrnoException | null) => void): Promise { - if (typeof closeConnections === 'function') { - callback = closeConnections; - closeConnections = false; - } - + async close(closeConnections = false): Promise { if (closeConnections) { this.closeConnections(); } @@ -765,10 +755,7 @@ export class Server extends EventEmitter { const { server } = this; // @ts-expect-error Let's make sure we can't access the server anymore. this.server = null; - const promise = util.promisify(server.close).bind(server)(); - return nodeify(promise, callback); + await util.promisify(server.close).bind(server)(); } - - return nodeify(Promise.resolve(), callback); } } diff --git a/src/tcp_tunnel_tools.ts b/src/tcp_tunnel_tools.ts index c2cb9ff0..3d429d0a 100644 --- a/src/tcp_tunnel_tools.ts +++ b/src/tcp_tunnel_tools.ts @@ -1,8 +1,7 @@ import net from 'node:net'; import { URL } from 'node:url'; -import { chain } from './chain'; -import { nodeify } from './utils/nodeify'; +import { chain } from './chain.js'; const runningServers: Record }> = {}; @@ -23,7 +22,6 @@ export async function createTunnel( verbose?: boolean; ignoreProxyCertificate?: boolean; }, - callback?: (error: Error | null, result?: string) => void, ): Promise { const parsedProxyUrl = new URL(proxyUrl); if (!['http:', 'https:'].includes(parsedProxyUrl.protocol)) { @@ -94,13 +92,12 @@ export async function createTunnel( }); }); - return nodeify(promise, callback); + return promise; } export async function closeTunnel( serverPath: string, - closeConnections: boolean | undefined, - callback: (error: Error | null, result?: boolean) => void, + closeConnections?: boolean, ): Promise { const { hostname, port } = new URL(`tcp://${serverPath}`); if (!hostname) throw new Error('serverPath must contain hostname'); @@ -131,5 +128,5 @@ export async function closeTunnel( }); })); - return nodeify(promise, callback); + return promise; } diff --git a/src/utils/get_basic.ts b/src/utils/get_basic.ts index 6ccbf608..d5e94dab 100644 --- a/src/utils/get_basic.ts +++ b/src/utils/get_basic.ts @@ -1,6 +1,6 @@ import type { URL } from 'node:url'; -import { decodeURIComponentSafe } from './decode_uri_component_safe'; +import { decodeURIComponentSafe } from './decode_uri_component_safe.js'; export const getBasicAuthorizationHeader = (url: URL): string => { const username = decodeURIComponentSafe(url.username); diff --git a/src/utils/nodeify.ts b/src/utils/nodeify.ts deleted file mode 100644 index 378124b8..00000000 --- a/src/utils/nodeify.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Replacement for Bluebird's Promise.nodeify() -export const nodeify = async (promise: Promise, callback?: (error: Error | null, result?: T) => void): Promise => { - if (typeof callback !== 'function') return promise; - - promise.then( - (result) => callback(null, result), - callback, - ).catch((error) => { - // Need to .catch because it doesn't crash the process on Node.js 14 - process.nextTick(() => { - throw error; - }); - }); - - return promise; -}; diff --git a/src/utils/valid_headers_only.ts b/src/utils/valid_headers_only.ts index 936851f6..a5f6b4f6 100644 --- a/src/utils/valid_headers_only.ts +++ b/src/utils/valid_headers_only.ts @@ -1,6 +1,6 @@ import { validateHeaderName, validateHeaderValue } from 'node:http'; -import { isHopByHopHeader } from './is_hop_by_hop_header'; +import { isHopByHopHeader } from './is_hop_by_hop_header.js'; /** * @see https://nodejs.org/api/http.html#http_message_rawheaders diff --git a/test/Dockerfile b/test/Dockerfile index 643033e1..d3c1ea11 100644 --- a/test/Dockerfile +++ b/test/Dockerfile @@ -1,4 +1,4 @@ -ARG NODE_IMAGE=node:18.20.8-bookworm +ARG NODE_IMAGE=node:20.19.6-bookworm FROM ${NODE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends chromium \ diff --git a/test/anonymize_proxy.js b/test/anonymize_proxy.js index 9ceeef3a..b18f3b13 100644 --- a/test/anonymize_proxy.js +++ b/test/anonymize_proxy.js @@ -1,15 +1,15 @@ -const _ = require('underscore'); -const util = require('util'); -const { expect, assert } = require('chai'); -const proxy = require('proxy'); -const http = require('http'); -const portastic = require('portastic'); -const basicAuthParser = require('basic-auth-parser'); -const request = require('request'); -const express = require('express'); - -const { anonymizeProxy, closeAnonymizedProxy, listenConnectAnonymizedProxy } = require('../src/index'); -const { expectThrowsAsync } = require('./utils/throws_async'); +import _ from 'underscore'; +import util from 'node:util'; +import { expect, assert } from 'chai'; +import proxy from 'proxy'; +import http from 'node:http'; +import portastic from 'portastic'; +import basicAuthParser from 'basic-auth-parser'; +import request from 'request'; +import express from 'express'; + +import { anonymizeProxy, closeAnonymizedProxy, listenConnectAnonymizedProxy } from '../dist/index.js'; +import { expectThrowsAsync } from './utils/throws_async.js'; let expressServer; let proxyServer; @@ -137,127 +137,78 @@ describe('utils.anonymizeProxy', function () { expectThrowsAsync(async () => { await anonymizeProxy({ url: 'https://whatever.com' }); }); }); - it('keeps already anonymous proxies (both with callbacks and promises)', () => { - return Promise.resolve() - .then(() => { - return anonymizeProxy('http://whatever:4567'); - }) - .then((anonymousProxyUrl) => { - expect(anonymousProxyUrl).to.eql('http://whatever:4567'); - }) - .then(() => { - return new Promise((resolve, reject) => { - anonymizeProxy('http://whatever:4567', (err, result) => { - if (err) return reject(err); - resolve(result); - }); - }); - }) - .then((anonymousProxyUrl) => { - expect(anonymousProxyUrl).to.eql('http://whatever:4567'); - }); - }); + it('keeps already anonymous proxies', async () => { + const anonymousProxyUrl = await anonymizeProxy('http://whatever:4567'); + expect(anonymousProxyUrl).to.eql('http://whatever:4567'); - it('anonymizes authenticated upstream proxy (both with callbacks and promises)', () => { - let proxyUrl1; - let proxyUrl2; - return Promise.resolve() - .then(() => { - return Promise.all([ - anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`), - new Promise((resolve, reject) => { - anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`, (err, result) => { - if (err) return reject(err); - resolve(result); - }); - }), - ]); - }) - .then((results) => { - [proxyUrl1, proxyUrl2] = results; - expect(proxyUrl1).to.not.contain(`${proxyPort}`); - expect(proxyUrl2).to.not.contain(`${proxyPort}`); - expect(proxyUrl1).to.not.equal(proxyUrl2); + const anonymousProxyUrl2 = await anonymizeProxy('http://whatever:4567'); + expect(anonymousProxyUrl2).to.eql('http://whatever:4567'); + }); - // Test call through proxy 1 - wasProxyCalled = false; - return requestPromised({ - uri: `http://localhost:${testServerPort}`, - proxy: proxyUrl1, - expectBodyContainsText: 'Hello World!', - }); - }) - .then(() => { - expect(wasProxyCalled).to.equal(true); - }) - .then(() => { - // Test call through proxy 2 - wasProxyCalled = false; - return requestPromised({ - uri: `http://localhost:${testServerPort}`, - proxy: proxyUrl2, - expectBodyContainsText: 'Hello World!', - }); - }) - .then(() => { - expect(wasProxyCalled).to.equal(true); - }) - .then(() => { - // Test again call through proxy 1 - wasProxyCalled = false; - return requestPromised({ - uri: `http://localhost:${testServerPort}`, - proxy: proxyUrl1, - expectBodyContainsText: 'Hello World!', - }); - }) - .then(() => { - expect(wasProxyCalled).to.equal(true); - }) - .then(() => closeAnonymizedProxy(proxyUrl1, true)) - .then((closed) => { - expect(closed).to.eql(true); + it('anonymizes authenticated upstream proxy', async () => { + const [proxyUrl1, proxyUrl2] = await Promise.all([ + anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`), + anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`), + ]); + + expect(proxyUrl1).to.not.contain(`${proxyPort}`); + expect(proxyUrl2).to.not.contain(`${proxyPort}`); + expect(proxyUrl1).to.not.equal(proxyUrl2); + + // Test call through proxy 1 + wasProxyCalled = false; + await requestPromised({ + uri: `http://localhost:${testServerPort}`, + proxy: proxyUrl1, + expectBodyContainsText: 'Hello World!', + }); + expect(wasProxyCalled).to.equal(true); + + // Test call through proxy 2 + wasProxyCalled = false; + await requestPromised({ + uri: `http://localhost:${testServerPort}`, + proxy: proxyUrl2, + expectBodyContainsText: 'Hello World!', + }); + expect(wasProxyCalled).to.equal(true); + + // Test again call through proxy 1 + wasProxyCalled = false; + await requestPromised({ + uri: `http://localhost:${testServerPort}`, + proxy: proxyUrl1, + expectBodyContainsText: 'Hello World!', + }); + expect(wasProxyCalled).to.equal(true); - // Test proxy is really closed - return requestPromised({ - uri: proxyUrl1, - }) - .then(() => { - assert.fail(); - }) - .catch((err) => { - expect(err.message).to.contain('ECONNREFUSED'); - }); - }) - .then(() => { - // Test callback-style - return new Promise((resolve, reject) => { - closeAnonymizedProxy(proxyUrl2, true, (err, closed) => { - if (err) return reject(err); - resolve(closed); - }); - }); - }) - .then((closed) => { - expect(closed).to.eql(true); + // Close proxy 1 and verify + const closed1 = await closeAnonymizedProxy(proxyUrl1, true); + expect(closed1).to.eql(true); - // Test the second-time call to close - return closeAnonymizedProxy(proxyUrl1, true); - }) - .then((closed) => { - expect(closed).to.eql(false); - - // Test callback-style - return new Promise((resolve, reject) => { - closeAnonymizedProxy(proxyUrl2, false, (err, closed2) => { - if (err) return reject(err); - resolve(closed2); - }); - }); - }) - .then((closed) => { - expect(closed).to.eql(false); + // Test proxy is really closed + try { + await requestPromised({ + uri: proxyUrl1, }); + assert.fail(); + } catch (err) { + // Node.js 20+ may return 'socket hang up' instead of 'ECONNREFUSED' + const validErrors = ['ECONNREFUSED', 'socket hang up']; + expect(validErrors.some((e) => err.message.includes(e))).to.equal(true); + } + + // Close proxy 2 + const closed2 = await closeAnonymizedProxy(proxyUrl2, true); + expect(closed2).to.eql(true); + + // Test the second-time call to close (should return false) + const closed1Again = await closeAnonymizedProxy(proxyUrl1, true); + expect(closed1Again).to.eql(false); + + // Test another second-time call to close + const closed2Again = await closeAnonymizedProxy(proxyUrl2, false); + expect(closed2Again).to.eql(false); }); it('handles many concurrent calls without port collision', () => { diff --git a/test/anonymize_proxy_no_password.js b/test/anonymize_proxy_no_password.js index e017118a..5a710aae 100644 --- a/test/anonymize_proxy_no_password.js +++ b/test/anonymize_proxy_no_password.js @@ -1,14 +1,14 @@ -const _ = require('underscore'); -const { expect, assert } = require('chai'); -const proxy = require('proxy'); -const http = require('http'); -const util = require('util'); -const portastic = require('portastic'); -const basicAuthParser = require('basic-auth-parser'); -const request = require('request'); -const express = require('express'); - -const { anonymizeProxy, closeAnonymizedProxy } = require('../src/index'); +import _ from 'underscore'; +import { expect, assert } from 'chai'; +import proxy from 'proxy'; +import http from 'node:http'; +import util from 'node:util'; +import portastic from 'portastic'; +import basicAuthParser from 'basic-auth-parser'; +import request from 'request'; +import express from 'express'; + +import { anonymizeProxy, closeAnonymizedProxy } from '../dist/index.js'; let expressServer; let proxyServer; @@ -93,20 +93,14 @@ const requestPromised = (opts) => { describe('utils.anonymizeProxyNoPassword', function () { // Need larger timeout for Travis CI this.timeout(5 * 1000); - it('anonymizes authenticated with no password upstream proxy (both with callbacks and promises)', () => { + it('anonymizes authenticated with no password upstream proxy', () => { let proxyUrl1; let proxyUrl2; return Promise.resolve() .then(() => { return Promise.all([ anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`), - new Promise((resolve, reject) => { - anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`, - (err, result) => { - if (err) return reject(err); - resolve(result); - }); - }), + anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`), ]); }) .then((results) => { @@ -165,37 +159,25 @@ describe('utils.anonymizeProxyNoPassword', function () { assert.fail(); }) .catch((err) => { - expect(err.message).to.contain('ECONNREFUSED'); - }); - }) - .then(() => { - // Test callback-style - return new Promise((resolve, reject) => { - closeAnonymizedProxy(proxyUrl2, true, (err, closed) => { - if (err) return reject(err); - resolve(closed); + // Node.js 20+ may return 'socket hang up' instead of 'ECONNREFUSED' + const validErrors = ['ECONNREFUSED', 'socket hang up']; + expect(validErrors.some((e) => err.message.includes(e))).to.equal(true); }); - }); }) - .then((closed) => { + .then(async () => { + // Test async/await style + const closed = await closeAnonymizedProxy(proxyUrl2, true); expect(closed).to.eql(true); // Test the second-time call to close return closeAnonymizedProxy(proxyUrl1, true); }) - .then((closed) => { + .then(async (closed) => { expect(closed).to.eql(false); - // Test callback-style - return new Promise((resolve, reject) => { - closeAnonymizedProxy(proxyUrl2, false, (err, closed) => { - if (err) return reject(err); - resolve(closed); - }); - }); - }) - .then((closed) => { - expect(closed).to.eql(false); + // Test async/await style + const closed2 = await closeAnonymizedProxy(proxyUrl2, false); + expect(closed2).to.eql(false); }); }); }); diff --git a/test/ee-memory-leak.js b/test/ee-memory-leak.js index e92d1ca0..b5ca6289 100644 --- a/test/ee-memory-leak.js +++ b/test/ee-memory-leak.js @@ -1,7 +1,7 @@ -const net = require('net'); -const http = require('http'); -const { assert } = require('chai'); -const ProxyChain = require('../src/index'); +import net from 'node:net'; +import http from 'node:http'; +import { assert } from 'chai'; +import * as ProxyChain from '../dist/index.js'; describe('ProxyChain server', () => { let proxyServer; @@ -23,7 +23,7 @@ describe('ProxyChain server', () => { server.close(); }); - it('does not leak events', (done) => { + it('does not leak events', async () => { let socket; let registeredCount; proxyServer.server.prependOnceListener('request', (request) => { @@ -31,31 +31,29 @@ describe('ProxyChain server', () => { registeredCount = socket.listenerCount('error'); }); - const callback = () => { - assert.equal(socket.listenerCount('error'), registeredCount); - done(); - }; + await proxyServer.listen(); + const proxyServerPort = proxyServer.server.address().port; - proxyServer.listen(async () => { - const proxyServerPort = proxyServer.server.address().port; + const requestCount = 20; - const requestCount = 20; - - const client = net.connect({ - host: 'localhost', - port: proxyServerPort, - }); + const client = net.connect({ + host: 'localhost', + port: proxyServerPort, + }); - client.setTimeout(100); + client.setTimeout(100); + await new Promise((resolve) => { client.on('timeout', () => { client.destroy(); - callback(); + resolve(); }); for (let i = 0; i < requestCount; i++) { client.write(`GET http://localhost:${port} HTTP/1.1\r\nhost: localhost:${port}\r\nconnection: keep-alive\r\n\r\n`); } }); + + assert.equal(socket.listenerCount('error'), registeredCount); }); }); diff --git a/test/http-agent.js b/test/http-agent.js index d4368f37..1d783627 100644 --- a/test/http-agent.js +++ b/test/http-agent.js @@ -1,14 +1,17 @@ -const fs = require('fs'); -const path = require('path'); -const http = require('http'); -const https = require('https'); -const { expect } = require('chai'); -const portastic = require('portastic'); -const proxy = require('proxy'); -const request = require('request'); - -const { Server } = require('../src/index'); -const { TargetServer } = require('./utils/target_server'); +import fs from 'node:fs'; +import path from 'node:path'; +import http from 'node:http'; +import https from 'node:https'; +import { fileURLToPath } from 'node:url'; +import { expect } from 'chai'; +import portastic from 'portastic'; +import proxy from 'proxy'; +import request from 'request'; + +import { Server } from '../dist/index.js'; +import { TargetServer } from './utils/target_server.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const sslKey = fs.readFileSync(path.join(__dirname, 'ssl.key')); const sslCrt = fs.readFileSync(path.join(__dirname, 'ssl.crt')); diff --git a/test/https-server.js b/test/https-server.js index 8ee0ad82..ab9eb57a 100644 --- a/test/https-server.js +++ b/test/https-server.js @@ -1,9 +1,13 @@ -const fs = require('fs'); -const path = require('path'); -const tls = require('tls'); -const { expect } = require('chai'); -const http = require('http'); -const { Server } = require('../src/index'); +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import tls from 'node:tls'; +import { fileURLToPath } from 'node:url'; +import { expect } from 'chai'; +import http from 'node:http'; +import { Server } from '../dist/index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const sslKey = fs.readFileSync(path.join(__dirname, 'ssl.key')); const sslCrt = fs.readFileSync(path.join(__dirname, 'ssl.crt')); @@ -17,8 +21,16 @@ it('handles TLS handshake failures gracefully and continues accepting connection let server; let badSocket; let goodSocket; + let targetServer; try { + // Create a local TCP server as the CONNECT target to avoid external network dependency. + targetServer = net.createServer((socket) => { + socket.on('error', () => {}); + }); + await new Promise((resolve) => targetServer.listen(0, '127.0.0.1', resolve)); + const targetPort = targetServer.address().port; + server = new Server({ port: 0, serverType: 'https', @@ -102,8 +114,8 @@ it('handles TLS handshake failures gracefully and continues accepting connection expect(goodSocketConnected).to.equal(true, 'Good socket should have connected'); - // Write the CONNECT request. - goodSocket.write('CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n'); + // Write the CONNECT request to local target server. + goodSocket.write(`CONNECT 127.0.0.1:${targetPort} HTTP/1.1\r\nHost: 127.0.0.1:${targetPort}\r\n\r\n`); const response = await new Promise((resolve, reject) => { const goodSocketTimeout = setTimeout(() => { @@ -134,8 +146,8 @@ it('handles TLS handshake failures gracefully and continues accepting connection expect(tlsErrors.length).to.be.equal(1); expect(tlsErrors[0].library).to.be.equal('SSL routines'); - expect(tlsErrors[0].reason).to.be.equal('unsupported protocol'); - expect(tlsErrors[0].code).to.be.equal('ERR_SSL_UNSUPPORTED_PROTOCOL'); + // Error message varies by OpenSSL version: 'unsupported protocol' (Node 20) vs 'unexpected message' (Node 22+) + expect(['unsupported protocol', 'unexpected message']).to.include(tlsErrors[0].reason); } finally { if (badSocket && !badSocket.destroyed) { badSocket.destroy(); @@ -146,6 +158,9 @@ it('handles TLS handshake failures gracefully and continues accepting connection if (server) { await server.close(true); } + if (targetServer) { + await new Promise((resolve) => targetServer.close(resolve)); + } } }); diff --git a/test/https-stress-test.js b/test/https-stress-test.js index 63b1715d..ac3b4650 100644 --- a/test/https-stress-test.js +++ b/test/https-stress-test.js @@ -1,11 +1,19 @@ -const fs = require('fs'); -const path = require('path'); -const tls = require('tls'); -const util = require('util'); -const request = require('request'); -const { expect } = require('chai'); -const { Server } = require('../src/index'); -const { TargetServer } = require('./utils/target_server'); +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import tls from 'node:tls'; +import util from 'node:util'; +import { fileURLToPath } from 'node:url'; +import request from 'request'; +import { expect } from 'chai'; +import { Server } from '../dist/index.js'; +import { TargetServer } from './utils/target_server.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Node.js 20+ enables HTTP keep-alive by default in the global agent, +// which causes connection tracking issues in tests. Disable it. +http.globalAgent.keepAlive = false; const sslKey = fs.readFileSync(path.join(__dirname, 'ssl.key')); const sslCrt = fs.readFileSync(path.join(__dirname, 'ssl.crt')); @@ -35,6 +43,9 @@ describe('HTTPS proxy stress testing', function () { serverType: 'https', httpsOptions: { key: sslKey, cert: sslCrt }, }); + // Node.js 20+ enables HTTP keep-alive by default, which causes connection + // tracking issues in tests. Disable keep-alive on the proxy server. + server.server.keepAliveTimeout = 0; await server.listen(); }); diff --git a/test/server.js b/test/server.js index 8089dd08..cd8acd09 100644 --- a/test/server.js +++ b/test/server.js @@ -1,25 +1,28 @@ -const fs = require('fs'); -const zlib = require('zlib'); -const path = require('path'); -const stream = require('stream'); -const childProcess = require('child_process'); -const tls = require('tls'); -const net = require('net'); -const dns = require('dns'); -const util = require('util'); -const { expect, assert } = require('chai'); -const proxy = require('proxy'); -const http = require('http'); -const https = require('https'); -const portastic = require('portastic'); -const request = require('request'); -const WebSocket = require('faye-websocket'); -const { gotScraping } = require('got-scraping'); - -const { parseAuthorizationHeader } = require('../src/utils/parse_authorization_header'); -const { Server, RequestError } = require('../src/index'); -const { TargetServer } = require('./utils/target_server'); -const ProxyChain = require('../src/index'); +import fs from 'node:fs'; +import zlib from 'node:zlib'; +import path from 'node:path'; +import stream from 'node:stream'; +import childProcess from 'node:child_process'; +import tls from 'node:tls'; +import net from 'node:net'; +import dns from 'node:dns'; +import util from 'node:util'; +import { fileURLToPath } from 'node:url'; +import { expect, assert } from 'chai'; +import proxy from 'proxy'; +import http from 'node:http'; +import https from 'node:https'; +import portastic from 'portastic'; +import request from 'request'; +import WebSocket from 'faye-websocket'; +import { gotScraping } from 'got-scraping'; + +import { parseAuthorizationHeader } from '../dist/utils/parse_authorization_header.js'; +import { Server, RequestError } from '../dist/index.js'; +import { TargetServer } from './utils/target_server.js'; +import * as ProxyChain from '../dist/index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); /* TODO - add following tests: @@ -69,58 +72,55 @@ const requestPromised = (opts) => { const wait = (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)); // Opens web page in puppeteer and returns the HTML content -const puppeteerGet = (url, proxyUrl) => { - // eslint-disable-next-line global-require - const puppeteer = require('puppeteer'); - - return (async () => { - const parsed = proxyUrl ? new URL(proxyUrl) : undefined; - - const args = [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage' - ]; - - const launchOpts = { - ignoreHTTPSErrors: true, - headless: 'new', - args - }; +const puppeteerGet = async (url, proxyUrl) => { + const { default: puppeteer } = await import('puppeteer'); - if (parsed) { - if (parsed.protocol === 'https:') { - args.push(`--proxy-server=${parsed.origin}`); - // For HTTPS proxies with self-signed certificates, - // ignore certificate errors on the proxy connection itself. - args.push('--ignore-certificate-errors'); - } else { - launchOpts.env = { - HTTP_PROXY: parsed.origin, - }; - } - } + const parsed = proxyUrl ? new URL(proxyUrl) : undefined; - const browser = await puppeteer.launch(launchOpts); + const args = [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage' + ]; - try { - const page = await browser.newPage(); + const launchOpts = { + ignoreHTTPSErrors: true, + headless: 'new', + args + }; - if (parsed) { - await page.authenticate({ - username: decodeURIComponent(parsed.username), - password: decodeURIComponent(parsed.password), - }); - } + if (parsed) { + if (parsed.protocol === 'https:') { + args.push(`--proxy-server=${parsed.origin}`); + // For HTTPS proxies with self-signed certificates, + // ignore certificate errors on the proxy connection itself. + args.push('--ignore-certificate-errors'); + } else { + launchOpts.env = { + HTTP_PROXY: parsed.origin, + }; + } + } - const response = await page.goto(url); - const text = await response.text(); + const browser = await puppeteer.launch(launchOpts); - return text; - } finally { - await browser.close(); + try { + const page = await browser.newPage(); + + if (parsed) { + await page.authenticate({ + username: decodeURIComponent(parsed.username), + password: decodeURIComponent(parsed.password), + }); } - })(); + + const response = await page.goto(url); + const text = await response.text(); + + return text; + } finally { + await browser.close(); + } }; // Opens web page in curl and returns the HTML content. @@ -190,7 +190,11 @@ const createTestSuite = ({ url: pathOrUrl[0] === '/' ? `${baseUrl}${pathOrUrl}` : pathOrUrl, key: sslKey, proxy: mainProxyUrl, - headers: {}, + headers: { + // Node.js 20+ enables HTTP keep-alive by default, which causes connection + // reuse between tests and breaks connection tracking. Force close. + Connection: 'close', + }, timeout: 30000, }; @@ -221,6 +225,10 @@ const createTestSuite = ({ return new Promise((resolve, reject) => { const upstreamProxyHttpServer = http.createServer(); + // Node.js 20+ enables HTTP keep-alive by default, which causes connection + // tracking issues in tests. Disable keep-alive on the upstream proxy server. + upstreamProxyHttpServer.keepAliveTimeout = 0; + // Setup upstream proxy authorization upstreamProxyHttpServer.authenticate = function (req, fn) { upstreamProxyRequestCount++; @@ -454,6 +462,10 @@ const createTestSuite = ({ mainProxyServer = new Server(opts); + // Node.js 20+ enables HTTP keep-alive by default, which causes connection + // tracking issues in tests. Disable keep-alive on the proxy server. + mainProxyServer.server.keepAliveTimeout = 0; + mainProxyServer.on('connectionClosed', ({ connectionId, stats }) => { assert.include(mainProxyServer.getConnectionIds(), connectionId); mainProxyServerConnectionsClosed.push(connectionId); @@ -644,7 +656,10 @@ const createTestSuite = ({ }); // NOTE: upstream proxy cannot handle non-standard headers - if (!useUpstreamProxy) { + // NOTE: Node.js 20+ has stricter HTTP client parsing that ignores --insecure-http-parser + // for invalid header names (spaces) and invalid status codes, so we skip these tests. + const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); + if (!useUpstreamProxy && nodeMajorVersion < 20) { _it('ignores non-standard server HTTP headers', () => { // Node 12+ uses a new HTTP parser (https://llhttp.org/), // which throws error on HTTP headers values with invalid chars. @@ -652,7 +667,6 @@ const createTestSuite = ({ // Note that after Node.js introduced a stricter HTTP parsing as a security hotfix // (https://snyk.io/blog/node-js-release-fixes-a-critical-http-security-vulnerability/) // this test broke down so we had to add NODE_OPTIONS=--insecure-http-parser to "npm test" command - const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); const skipInvalidHeaderValue = nodeMajorVersion >= 12; const opts = getRequestOpts(`/get-non-standard-headers?skipInvalidHeaderValue=${skipInvalidHeaderValue ? '1' : '0'}`); @@ -1340,50 +1354,50 @@ describe('non-200 upstream connect response', () => { } }); - it('fails downstream with 590', (done) => { + it('fails downstream with 590', async () => { const server = http.createServer(); server.on('connect', (_request, socket) => { socket.once('error', () => {}); socket.end('HTTP/1.1 403 Forbidden\r\ncontent-length: 1\r\n\r\na'); }); - server.listen(() => { - const serverPort = server.address().port; - const proxyServer = new ProxyChain.Server({ - port: 0, - prepareRequestFunction: () => { - return { - upstreamProxyUrl: `http://localhost:${serverPort}`, - }; + await new Promise((resolve) => server.listen(resolve)); + const serverPort = server.address().port; + const proxyServer = new ProxyChain.Server({ + port: 0, + prepareRequestFunction: () => { + return { + upstreamProxyUrl: `http://localhost:${serverPort}`, + }; + }, + }); + await proxyServer.listen(); + const proxyServerPort = proxyServer.port; + + await new Promise((resolve) => { + const req = http.request({ + method: 'CONNECT', + host: 'localhost', + port: proxyServerPort, + path: 'example.com:443', + headers: { + host: 'example.com:443', }, }); - proxyServer.listen(() => { - const proxyServerPort = proxyServer.port; - - const req = http.request({ - method: 'CONNECT', - host: 'localhost', - port: proxyServerPort, - path: 'example.com:443', - headers: { - host: 'example.com:443', - }, - }); - req.once('connect', (response, socket, head) => { - expect(response.statusCode).to.equal(590); - expect(response.statusMessage).to.equal('UPSTREAM403'); - expect(head.length).to.equal(0); - success = true; + req.once('connect', (response, socket, head) => { + expect(response.statusCode).to.equal(590); + expect(response.statusMessage).to.equal('UPSTREAM403'); + expect(head.length).to.equal(0); + success = true; - socket.once('close', () => { - proxyServer.close(); - server.close(); + socket.once('close', () => { + proxyServer.close(); + server.close(); - done(); - }); + resolve(); }); - - req.end(); }); + + req.end(); }); }); }); diff --git a/test/socks.js b/test/socks.js index 2a9e8dc8..1c7f18bf 100644 --- a/test/socks.js +++ b/test/socks.js @@ -1,8 +1,8 @@ -const portastic = require('portastic'); -const socksv5 = require('socksv5'); -const { gotScraping } = require('got-scraping'); -const { expect } = require('chai'); -const ProxyChain = require('../src/index'); +import portastic from 'portastic'; +import socksv5 from 'socksv5'; +import { gotScraping } from 'got-scraping'; +import { expect } from 'chai'; +import * as ProxyChain from '../dist/index.js'; describe('SOCKS protocol', () => { let socksServer; @@ -15,64 +15,50 @@ describe('SOCKS protocol', () => { if (anonymizeProxyUrl) ProxyChain.closeAnonymizedProxy(anonymizeProxyUrl, true); }); - it('works without auth', (done) => { - portastic.find({ min: 50000, max: 50250 }).then((ports) => { - const [socksPort, proxyPort] = ports; - socksServer = socksv5.createServer((info, accept) => { - accept(); - }); - socksServer.listen(socksPort, '0.0.0.0', () => { - socksServer.useAuth(socksv5.auth.None()); + it('works without auth', async () => { + const ports = await portastic.find({ min: 50000, max: 50250 }); + const [socksPort, proxyPort] = ports; + socksServer = socksv5.createServer((info, accept) => { + accept(); + }); + await new Promise((resolve) => socksServer.listen(socksPort, '0.0.0.0', resolve)); + socksServer.useAuth(socksv5.auth.None()); - proxyServer = new ProxyChain.Server({ - port: proxyPort, - prepareRequestFunction() { - return { - upstreamProxyUrl: `socks://127.0.0.1:${socksPort}`, - }; - }, - }); - proxyServer.listen(() => { - gotScraping.get({ url: 'https://example.com', proxyUrl: `http://127.0.0.1:${proxyPort}` }) - .then((response) => { - expect(response.body).to.contain('Example Domain'); - done(); - }) - .catch(done); - }); - }); + proxyServer = new ProxyChain.Server({ + port: proxyPort, + prepareRequestFunction() { + return { + upstreamProxyUrl: `socks://127.0.0.1:${socksPort}`, + }; + }, }); + await proxyServer.listen(); + const response = await gotScraping.get({ url: 'https://example.com', proxyUrl: `http://127.0.0.1:${proxyPort}` }); + expect(response.body).to.contain('Example Domain'); }).timeout(10 * 1000); - it('work with auth', (done) => { - portastic.find({ min: 50250, max: 50500 }).then((ports) => { - const [socksPort, proxyPort] = ports; - socksServer = socksv5.createServer((info, accept) => { - accept(); - }); - socksServer.listen(socksPort, '0.0.0.0', () => { - socksServer.useAuth(socksv5.auth.UserPassword((user, password, cb) => { - cb(user === 'proxy-ch@in' && password === 'rules!'); - })); + it('work with auth', async () => { + const ports = await portastic.find({ min: 50250, max: 50500 }); + const [socksPort, proxyPort] = ports; + socksServer = socksv5.createServer((info, accept) => { + accept(); + }); + await new Promise((resolve) => socksServer.listen(socksPort, '0.0.0.0', resolve)); + socksServer.useAuth(socksv5.auth.UserPassword((user, password, cb) => { + cb(user === 'proxy-ch@in' && password === 'rules!'); + })); - proxyServer = new ProxyChain.Server({ - port: proxyPort, - prepareRequestFunction() { - return { - upstreamProxyUrl: `socks://proxy-ch@in:rules!@127.0.0.1:${socksPort}`, - }; - }, - }); - proxyServer.listen(() => { - gotScraping.get({ url: 'https://example.com', proxyUrl: `http://127.0.0.1:${proxyPort}` }) - .then((response) => { - expect(response.body).to.contain('Example Domain'); - done(); - }) - .catch(done); - }); - }); + proxyServer = new ProxyChain.Server({ + port: proxyPort, + prepareRequestFunction() { + return { + upstreamProxyUrl: `socks://proxy-ch@in:rules!@127.0.0.1:${socksPort}`, + }; + }, }); + await proxyServer.listen(); + const response = await gotScraping.get({ url: 'https://example.com', proxyUrl: `http://127.0.0.1:${proxyPort}` }); + expect(response.body).to.contain('Example Domain'); }).timeout(10 * 1000); it('works with anonymizeProxy', (done) => { diff --git a/test/tcp_tunnel.js b/test/tcp_tunnel.js index 1b9705b4..74734b83 100644 --- a/test/tcp_tunnel.js +++ b/test/tcp_tunnel.js @@ -1,10 +1,10 @@ -const net = require('net'); -const { expect, assert } = require('chai'); -const http = require('http'); -const proxy = require('proxy'); +import net from 'node:net'; +import { expect, assert } from 'chai'; +import http from 'node:http'; +import proxy from 'proxy'; -const { createTunnel, closeTunnel } = require('../src/index'); -const { expectThrowsAsync } = require('./utils/throws_async'); +import { createTunnel, closeTunnel } from '../dist/index.js'; +import { expectThrowsAsync } from './utils/throws_async.js'; const destroySocket = (socket) => new Promise((resolve, reject) => { if (!socket || socket.destroyed) return resolve(); @@ -78,7 +78,7 @@ describe('tcp_tunnel.createTunnel', () => { .finally(() => closeServer(proxyServer, proxyServerConnections)) .finally(() => closeServer(targetService, targetServiceConnections)); }); - it('correctly tunnels to tcp service and then is able to close the connection when used with callbacks', () => { + it('correctly tunnels to tcp service and then is able to close the connection (async/await)', async () => { const proxyServerConnections = []; const proxyServer = proxy(http.createServer()); @@ -93,19 +93,16 @@ describe('tcp_tunnel.createTunnel', () => { conn.on('error', (err) => { throw err; }); }); - return serverListen(proxyServer, 0) - .then(() => serverListen(targetService, 0)) - .then((targetServicePort) => new Promise((resolve, reject) => { - createTunnel(`http://localhost:${proxyServer.address().port}`, `localhost:${targetServicePort}`, {}, (err, tunnel) => { - if (err) return reject(err); - return resolve(tunnel); - }); - }).then((tunnel) => closeTunnel(tunnel, true)) - .then((result) => { - assert.equal(result, true); - })) - .finally(() => closeServer(proxyServer, proxyServerConnections)) - .finally(() => closeServer(targetService, targetServiceConnections)); + try { + await serverListen(proxyServer, 0); + const targetServicePort = await serverListen(targetService, 0); + const tunnel = await createTunnel(`http://localhost:${proxyServer.address().port}`, `localhost:${targetServicePort}`, {}); + const result = await closeTunnel(tunnel, true); + assert.equal(result, true); + } finally { + await closeServer(proxyServer, proxyServerConnections); + await closeServer(targetService, targetServiceConnections); + } }); it('creates tunnel that is able to transfer data', () => { let tunnel; diff --git a/test/tools.js b/test/tools.js index 8319453c..c727d1ad 100644 --- a/test/tools.js +++ b/test/tools.js @@ -1,8 +1,7 @@ -const { expect } = require('chai'); -const { redactUrl } = require('../src/utils/redact_url'); -const { isHopByHopHeader } = require('../src/utils/is_hop_by_hop_header'); -const { parseAuthorizationHeader } = require('../src/utils/parse_authorization_header'); -const { nodeify } = require('../src/utils/nodeify'); +import { expect } from 'chai'; +import { redactUrl } from '../dist/utils/redact_url.js'; +import { isHopByHopHeader } from '../dist/utils/is_hop_by_hop_header.js'; +import { parseAuthorizationHeader } from '../dist/utils/parse_authorization_header.js'; describe('tools.redactUrl()', () => { it('works', () => { @@ -135,53 +134,3 @@ describe('tools.parseAuthorizationHeader()', () => { }); }); }); - -const asyncFunction = async (throwError) => { - if (throwError) throw new Error('Test error'); - return 123; -}; - -describe('tools.nodeify()', () => { - it('works', async () => { - { - // Test promised result - const promise = asyncFunction(false); - const result = await nodeify(promise, null); - expect(result).to.eql(123); - } - - { - // Test promised exception - const promise = asyncFunction(true); - try { - await nodeify(promise, null); - throw new Error('This should not be reached!'); - } catch (e) { - expect(e.message).to.eql('Test error'); - } - } - - { - // Test callback result - const promise = asyncFunction(false); - await new Promise((resolve) => { - nodeify(promise, (error, result) => { - expect(result).to.eql(123); - resolve(); - }); - }); - } - - { - // Test callback error - const promise = asyncFunction(true); - await new Promise((resolve) => { - nodeify(promise, (error, result) => { - expect(result, undefined); - expect(error.message).to.eql('Test error'); - resolve(); - }); - }); - } - }); -}); diff --git a/test/utils/run_locally.js b/test/utils/run_locally.js index 17533224..b6fbd86a 100644 --- a/test/utils/run_locally.js +++ b/test/utils/run_locally.js @@ -9,9 +9,9 @@ * */ -const http = require('http'); -const proxy = require('proxy'); // eslint-disable-line import/no-extraneous-dependencies -const { Server } = require('../../src/server'); +import http from 'node:http'; +import proxy from 'proxy'; // eslint-disable-line import/no-extraneous-dependencies +import { Server } from '../../dist/index.js'; // Set up upstream proxy with no auth const upstreamProxyHttpServer = http.createServer(); diff --git a/test/utils/target_server.js b/test/utils/target_server.js index be39296d..946aad8e 100644 --- a/test/utils/target_server.js +++ b/test/utils/target_server.js @@ -1,11 +1,11 @@ -const http = require('http'); -const https = require('https'); -const util = require('util'); -const express = require('express'); -const bodyParser = require('body-parser'); -const WebSocket = require('ws'); -const basicAuth = require('basic-auth'); -const _ = require('underscore'); +import http from 'node:http'; +import https from 'node:https'; +import util from 'node:util'; +import express from 'express'; +import bodyParser from 'body-parser'; +import { WebSocketServer } from 'ws'; +import basicAuth from 'basic-auth'; +import _ from 'underscore'; /** * A HTTP server used for testing. It supports HTTPS and web sockets. @@ -42,8 +42,12 @@ class TargetServer { this.httpServer = http.createServer(this.app); } + // Node.js 20+ enables HTTP keep-alive by default, which causes connection + // tracking issues in tests. Disable keep-alive on the target server. + this.httpServer.keepAliveTimeout = 0; + // Web socket server for upgraded HTTP connections - this.wsUpgServer = new WebSocket.Server({ server: this.httpServer }); + this.wsUpgServer = new WebSocketServer({ server: this.httpServer }); this.wsUpgServer.on('connection', this.onWsConnection.bind(this)); } @@ -189,4 +193,4 @@ class TargetServer { } } -exports.TargetServer = TargetServer; +export { TargetServer }; diff --git a/test/utils/testing_tcp_service.js b/test/utils/testing_tcp_service.js index 5db3431e..53cda5e6 100644 --- a/test/utils/testing_tcp_service.js +++ b/test/utils/testing_tcp_service.js @@ -1,4 +1,4 @@ -const net = require('net'); +import net from 'node:net'; // TODO: please move this into ./test dir diff --git a/test/utils/throws_async.js b/test/utils/throws_async.js index c7108924..407c3153 100644 --- a/test/utils/throws_async.js +++ b/test/utils/throws_async.js @@ -1,4 +1,4 @@ -const { expect } = require('chai'); +import { expect } from 'chai'; /** * Expect an async function to throw @@ -22,4 +22,4 @@ const expectThrowsAsync = async (func, errorMessage) => { } }; -exports.expectThrowsAsync = expectThrowsAsync; +export { expectThrowsAsync }; diff --git a/tsconfig.json b/tsconfig.json index 577dddbb..05437273 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,11 @@ { - "extends": "@apify/tsconfig", - "compilerOptions": { - "outDir": "dist" - }, - "include": [ - "src" - ] + "extends": "@apify/tsconfig", + "compilerOptions": { + "outDir": "dist", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "verbatimModuleSyntax": true + }, + "include": ["src"] }