diff --git a/.babelrc b/.babelrc deleted file mode 100644 index ec553ce6..00000000 --- a/.babelrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - presets: ["es2015", "stage-0"] -} \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 9e9c7270..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "extends": "airbnb-base", - "plugins": [ - "import", - "promise" - ], - "rules": { - "indent": ["error", 4], - "no-underscore-dangle": [2, { - "allow": ["_id"] - }], - "no-use-before-define": 0, - "no-param-reassign": 0, - "consistent-return": 0, - "array-callback-return": 0, - "arrow-body-style": 0, - "no-plusplus": 0, - "strict": ["error", "global"], - "max-len": ["error", 150], - "no-undef": 0, - "func-names": 0, - "import/prefer-default-export": 0, - "import/no-absolute-path": 0, - "import/no-extraneous-dependencies": ["error", { "devDependencies": ["**/test/*.js"] }], - "no-underscore-dangle": 0, - "radix": 0, - "no-continue": 0, - "import/no-cycle": 1, - "max-classes-per-file": 0, - "prefer-destructuring": 0, - "no-console": 0 - } -} diff --git a/.github/dependabot.yml b/.github/dependabot.yaml similarity index 100% rename from .github/dependabot.yml rename to .github/dependabot.yaml diff --git a/.github/scripts/before-beta-release.cjs b/.github/scripts/before-beta-release.cjs new file mode 100644 index 00000000..2cc1f171 --- /dev/null +++ b/.github/scripts/before-beta-release.cjs @@ -0,0 +1,37 @@ +const { execSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const PKG_JSON_PATH = path.join(__dirname, '..', '..', 'package.json'); + +// eslint-disable-next-line import/no-dynamic-require +const pkgJson = require(PKG_JSON_PATH); + +const PACKAGE_NAME = pkgJson.name; +const VERSION = pkgJson.version; + +const nextVersion = getNextVersion(VERSION); +// eslint-disable-next-line no-console +console.log(`before-deploy: Setting version to ${nextVersion}`); +pkgJson.version = nextVersion; + +fs.writeFileSync(PKG_JSON_PATH, `${JSON.stringify(pkgJson, null, 2)}\n`); + +function getNextVersion(version) { + // `pnpm view` matches `npm view`/`npm show` semantics; using pnpm here keeps + // the toolchain consistent with the rest of the workflow. + const versionString = execSync(`pnpm view ${PACKAGE_NAME} versions --json`, { encoding: 'utf8' }); + const versions = JSON.parse(versionString); + + if (versions.some((v) => v === VERSION)) { + // eslint-disable-next-line no-console + console.error(`before-deploy: A release with version ${VERSION} already exists. Please increment version accordingly.`); + process.exit(1); + } + + const prereleaseNumbers = versions + .filter((v) => (v.startsWith(VERSION) && v.includes('-'))) + .map((v) => Number(v.match(/\.(\d+)$/)[1])); + const lastPrereleaseNumber = Math.max(-1, ...prereleaseNumbers); + return `${version}-beta.${lastPrereleaseNumber + 1}`; +} diff --git a/.github/scripts/before-beta-release.js b/.github/scripts/before-beta-release.js deleted file mode 100644 index 8f898565..00000000 --- a/.github/scripts/before-beta-release.js +++ /dev/null @@ -1,32 +0,0 @@ -const path = require('path'); -const fs = require('fs'); -const { execSync } = require('child_process'); - -const PKG_JSON_PATH = path.join(__dirname, '..', '..', 'package.json'); - -const pkgJson = require(PKG_JSON_PATH); - -const PACKAGE_NAME = pkgJson.name; -const VERSION = pkgJson.version; - -const nextVersion = getNextVersion(VERSION); -console.log(`before-deploy: Setting version to ${nextVersion}`); -pkgJson.version = nextVersion; - -fs.writeFileSync(PKG_JSON_PATH, JSON.stringify(pkgJson, null, 2) + '\n'); - -function getNextVersion(version) { - const versionString = execSync(`npm show ${PACKAGE_NAME} versions --json`, { encoding: 'utf8'}); - const versions = JSON.parse(versionString); - - if (versions.some(v => v === VERSION)) { - console.error(`before-deploy: A release with version ${VERSION} already exists. Please increment version accordingly.`); - process.exit(1); - } - - const prereleaseNumbers = versions - .filter(v => (v.startsWith(VERSION) && v.includes('-'))) - .map(v => Number(v.match(/\.(\d+)$/)[1])); - const lastPrereleaseNumber = Math.max(-1, ...prereleaseNumbers); - return `${version}-beta.${lastPrereleaseNumber + 1}` -} diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 00000000..244c0064 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,164 @@ +# This workflow runs for every pull request to lint and test the proposed changes. + +name: Check + +on: + pull_request: + workflow_call: + inputs: + bun_e2e_mode: + description: 'Bun e2e suite scope: `compatible` (curated subset known to pass) or `full` (entire suite).' + type: string + default: compatible + required: false + workflow_dispatch: + inputs: + bun_e2e_mode: + description: 'Bun e2e suite scope' + type: choice + options: + - compatible + - full + default: compatible + required: true + +jobs: + lint: + name: Lint + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} + runs-on: ubuntu-24.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 pnpm and dependencies + uses: apify/actions/pnpm-install@v1.1.2 + + - name: Lint code + run: pnpm run lint + + unit: + name: Unit tests (Node.js ${{ matrix.node-version }}) + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} + runs-on: ubuntu-24.04 + + strategy: + fail-fast: false + matrix: + # pnpm 10 supports Node ≥18, so Node 20 stays in the matrix. + # Node 26 is intentionally NOT here yet: a pre-existing tsx + yargs (16.x + # CJS bin) + Node 26 ESM-loader incompatibility breaks `mocha` under the + # repo's `node-option: ["import=tsx"]` config. Reproducible on `master` + # with plain `npm install`, so it's not a migration regression — but the + # fix belongs in a separate change. + node-version: [20, 22, 24] + + 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: Install pnpm and dependencies + uses: apify/actions/pnpm-install@v1.1.2 + + - name: Run unit tests + run: pnpm run test:unit + + e2e: + name: E2E tests (Node.js 24) + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} + runs-on: ubuntu-24.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 pnpm and dependencies + uses: apify/actions/pnpm-install@v1.1.2 + + # pnpm caches the package store but not puppeteer's browser cache + # (`~/.cache/puppeteer/`). On warm-cache CI runs, pnpm just symlinks + # from the store and skips re-running `postinstall`, so the Chromium + # download never happens. `pnpm rebuild` forces the script to run. + - name: Ensure puppeteer Chromium is downloaded + run: pnpm rebuild puppeteer + + - name: Add localhost-test to Linux hosts file + run: sudo echo "127.0.0.1 localhost-test" | sudo tee -a /etc/hosts + + - name: Run E2E tests + run: pnpm run test:e2e + + bun_unit: + name: Unit tests (Bun) + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Use Node.js 24 + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install pnpm and dependencies + uses: apify/actions/pnpm-install@v1.1.2 + + - name: Run unit tests with Bun + run: pnpm run test:bun + + bun_e2e: + name: E2E tests (Bun, ${{ inputs.bun_e2e_mode || 'compatible' }}) + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Use Node.js 24 + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install pnpm and dependencies + uses: apify/actions/pnpm-install@v1.1.2 + + # See note on the Node E2E job — same fix needed here. + - name: Ensure puppeteer Chromium is downloaded + run: pnpm rebuild puppeteer + + - name: Add localhost-test to Linux hosts file + run: sudo echo "127.0.0.1 localhost-test" | sudo tee -a /etc/hosts + + - name: Run E2E tests (Bun, ${{ inputs.bun_e2e_mode || 'compatible' }}) + run: pnpm run test:bun:e2e:${{ inputs.bun_e2e_mode || 'compatible' }} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml deleted file mode 100644 index 98974c47..00000000 --- a/.github/workflows/check.yml +++ /dev/null @@ -1,74 +0,0 @@ -# This workflow runs for every pull request to lint and test the proposed changes. - -name: Check - -on: - pull_request: - -jobs: - # NPM install is done in a separate job and cached to speed up the following jobs. - build_and_test: - name: Build & Test - if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [ubuntu-latest] # add windows-latest later - node-version: [10, 12, 14] - - steps: - - - uses: actions/checkout@v2 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - - name: Cache Node Modules - if: ${{ matrix.node-version == 14 }} - uses: actions/cache@v2 - with: - path: | - node_modules - build - key: cache-${{ github.run_id }}-v14 - - - name: Install Dependencies - run: npm install - - - name: Add localhost-test to Linux hosts file - if: ${{ matrix.os == 'ubuntu-latest' }} - run: sudo echo "127.0.0.1 localhost-test" | sudo tee -a /etc/hosts -# - -# name: Add localhost-test to Windows hosts file -# if: ${{ matrix.os == 'windows-latest' }} -# run: echo "`n127.0.0.1 localhost-test">>C:\Windows\System32\drivers\etc\hosts - - - name: Run Tests - run: npm test - - lint: - name: Lint - needs: [build_and_test] - runs-on: ubuntu-latest - - steps: - - - uses: actions/checkout@v2 - - - name: Use Node.js 14 - uses: actions/setup-node@v1 - with: - node-version: 14 - - - name: Load Cache - uses: actions/cache@v2 - with: - path: | - node_modules - build - key: cache-${{ github.run_id }}-v14 - - - run: npm run lint diff --git a/.github/workflows/pr_toolkit.yml b/.github/workflows/pr_toolkit.yml deleted file mode 100644 index ef650376..00000000 --- a/.github/workflows/pr_toolkit.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Apify pull request toolkit - -on: - pull_request: - branches: - - master - -jobs: - apify-pr-toolkit: - runs-on: ubuntu-20.04 - steps: - - name: clone pull-request-toolkit-action - uses: actions/checkout@v2 - with: - repository: apify/pull-request-toolkit-action - path: ./.github/actions/pull-request-toolkit-action - - - name: run pull-request-toolkit action - uses: ./.github/actions/pull-request-toolkit-action - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - org-token: ${{ secrets.PULL_REQUEST_TOOLKIT_ACTION_GITHUB_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..fa75f678 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,63 @@ +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] + +# Necessary permissions for publishing to NPM with OIDC +permissions: + 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-24.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Use Node.js 24 + uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: 'https://registry.npmjs.org' + + - name: Install pnpm and dependencies + uses: apify/actions/pnpm-install@v1.1.2 + + # 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 + + # `pnpm publish` honours the dist-tag flag and keeps the workflow on a + # single package manager. `--no-git-checks` is required because the + # `release` event checks out a tag in detached HEAD, which trips pnpm's + # default "publish only from a tracked branch" guard. + - name: Publish to NPM + run: pnpm publish --tag ${{ steps.get_release_tag.outputs.release_tag }} --no-git-checks + + # 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/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index d9bac361..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,121 +0,0 @@ -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 ] - -jobs: - # NPM install is done in a separate job and cached to speed up the following jobs. - build_and_test: - name: Build & Test - if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [ubuntu-latest] # add windows-latest later - node-version: [10, 12, 14] - - steps: - - - uses: actions/checkout@v2 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - - name: Cache Node Modules - if: ${{ matrix.node-version == 14 }} - uses: actions/cache@v2 - with: - path: | - node_modules - build - key: cache-${{ github.run_id }}-v14 - - - name: Install Dependencies - run: npm install - - name: Add localhost-test to Linux hosts file - if: ${{ matrix.os == 'ubuntu-latest' }} - run: sudo echo "127.0.0.1 localhost-test" | sudo tee -a /etc/hosts -# - -# name: Add localhost-test to Windows hosts file -# if: ${{ matrix.os == 'windows-latest' }} -# run: echo "`n127.0.0.1 localhost-test">>C:\Windows\System32\drivers\etc\hosts - - - name: Run Tests - run: npm test - - lint: - name: Lint - needs: [build_and_test] - runs-on: ubuntu-latest - - steps: - - - uses: actions/checkout@v2 - - - name: Use Node.js 14 - uses: actions/setup-node@v1 - with: - node-version: 14 - - - name: Load Cache - uses: actions/cache@v2 - with: - path: | - node_modules - build - key: cache-${{ github.run_id }}-v14 - - - run: npm run lint - - - # The deploy job is long but there are only 2 important parts. NPM publish - # and triggering of docker image builds in the apify-actor-docker repo. - deploy: - name: Publish to NPM - needs: [lint] - runs-on: ubuntu-latest - steps: - - - uses: actions/checkout@v2 - - - uses: actions/setup-node@v1 - with: - node-version: 14 - registry-url: https://registry.npmjs.org/ - - - name: Load Cache - uses: actions/cache@v2 - with: - path: | - node_modules - build - key: cache-${{ github.run_id }}-v14 - - - # Determine if this is a beta or latest release - name: Set Release Tag - run: echo "RELEASE_TAG=$(if [ ${{ github.event_name }} = release ]; then echo latest; else echo beta; fi)" >> $GITHUB_ENV - - - # Check version consistency and increment pre-release version number for beta only. - name: Bump pre-release version - if: env.RELEASE_TAG == 'beta' - run: node ./.github/scripts/before-beta-release.js - - - name: Publish to NPM - run: NODE_AUTH_TOKEN=${{secrets.NPM_TOKEN}} npm publish --tag ${{ env.RELEASE_TAG }} --access public - - - # Latest version is tagged by the release process so we only tag beta here. - name: Tag Version - if: env.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/.gitignore b/.gitignore index ac184136..f309e3b3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,7 @@ pids .idea yarn.lock docs -package-lock.json \ No newline at end of file +package-lock.json +.nyc_output +dist +.vscode diff --git a/.mocharc.json b/.mocharc.json new file mode 100644 index 00000000..68ed90b5 --- /dev/null +++ b/.mocharc.json @@ -0,0 +1,3 @@ +{ + "node-option": ["import=tsx"] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f76b117..2de9ff3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,73 @@ -1.0.2 / 2021-04-14 -================== +# 3.0.0 / 2026-05-19 + +This is a major release that modernizes the codebase. The runtime behavior of the +proxy server itself is unchanged; the breaking changes are about how you import, +package, and call the library. + +- **BREAKING:** Dropped support for Node.js older than `20.11`. The minimum supported + version is now Node.js `20.11`. + See [#638](https://github.com/apify/proxy-chain/pull/638), + [#654](https://github.com/apify/proxy-chain/pull/654). +- **BREAKING:** The package is now ESM-only (`"type": "module"` in `package.json`). + Use `import` instead of `require()`. CommonJS consumers must migrate to ESM + (or use a dynamic `import()`). + See [#654](https://github.com/apify/proxy-chain/pull/654). +- **BREAKING:** Removed Node.js-style callbacks from the entire public API. + `Server#listen()`, `Server#close()`, `anonymizeProxy()`, + `closeAnonymizedProxy()`, `createTunnel()`, and `closeTunnel()` now return a + `Promise` only - use `await` or `.then()`. + See [#656](https://github.com/apify/proxy-chain/pull/656). + +- **BREAKING:** `CustomResponse` is now a `type`-only export. If you were importing + it as a value, switch to `import type { CustomResponse } from 'proxy-chain'`. + See [#654](https://github.com/apify/proxy-chain/pull/654). +- Upgraded TypeScript to v5 and adopted `NodeNext` module resolution with + `verbatimModuleSyntax`. + See [#655](https://github.com/apify/proxy-chain/pull/655). +- Added Bun runtime support. The library now loads cleanly under Bun and is + validated on every PR via a dedicated `Unit tests (Bun)` job, plus an + `E2E tests (Bun, compatible)` job that runs a curated Bun-safe subset of the + e2e suite. A `full` toggle for running the entire e2e suite under Bun is + available via workflow dispatch. + See [#650](https://github.com/apify/proxy-chain/pull/650). +- Internal: removed the unused `typeSocket` assertion helper. + See [#653](https://github.com/apify/proxy-chain/pull/653). +- Internal: reorganized tests into `test/unit/` and `test/e2e/` directories + and split the CI workflow into separate `unit`, `e2e`, and `lint` jobs. + See [#651](https://github.com/apify/proxy-chain/pull/651). + +# 2.0.1 / 2022-05-02 + +- Simplify code, fix tests, move to TypeScript [#162](https://github.com/apify/proxy-chain/pull/162) +- Bugfix: Memory leak in createTunnel [#160](https://github.com/apify/proxy-chain/issues/160) +- Bugfix: Proxy fails to handle non-standard HTTP response in HTTP forwarding mode, on certain websites [#107](https://github.com/apify/proxy-chain/issues/107) +- Pass proxyChainId to tunnelConnectResponded [#173](https://github.com/apify/proxy-chain/pull/173) +- feat: accept custom port for proxy anonymization [#214](https://github.com/apify/proxy-chain/pull/214) +- fix: socket close race condition +- feat: closeConnection by id [#176](https://github.com/apify/proxy-chain/pull/176) +- feat: custom dns lookup [#175](https://github.com/apify/proxy-chain/pull/175) + +# 1.0.3 / 2021-08-17 + +- Fixed `EventEmitter` memory leak (see issue [#81](https://github.com/apify/proxy-chain/issues/81)) +- Added automated tests for Node 16 +- Updated dev dependencies + +# 1.0.2 / 2021-04-14 + - Bugfix: `closeTunnel()` function didn't work because of `runningServers[port].connections.forEach is not a function` error (see issue [#127](https://github.com/apify/proxy-chain/issues/127)) -1.0.1 / 2021-04-09 -================== - - Bugfix: `parseUrl()` result now always includes port for `http(s)`, `ftp` and `ws(s)` (even if explicitly specified port is the default one) - This fixes [#123](https://github.com/apify/proxy-chain/issues/123). +# 1.0.1 / 2021-04-09 + +- Bugfix: `parseUrl()` result now always includes port for `http(s)`, `ftp` and `ws(s)` (even if explicitly specified port is the default one) + This fixes [#123](https://github.com/apify/proxy-chain/issues/123). + +# 1.0.0 / 2021-03-17 -1.0.0 / 2021-03-17 -=================== - **BREAKING:** The `parseUrl()` function slightly changed its behavior (see README for details): - it no longer returns an object on invalid URLs and throws an exception instead - it URI-decodes username and password if possible - (if not, the function keeps the username and password as is) + (if not, the function keeps the username and password as is) - it adds back `auth` property for better backwards compatibility - The above change should make it possible to pass upstream proxy URLs containing special characters, such as `http://user:pass:wrd@proxy.example.com` @@ -36,58 +91,58 @@ - Various code improvements and better tests. - Updated packages. -0.4.9 / 2021-01-26 -=================== +# 0.4.9 / 2021-01-26 + - Bugfix: Added back the `scheme` field to result from `parseUrl()` -0.4.8 / 2021-01-26 -=================== +# 0.4.8 / 2021-01-26 + - Bugfix: `parseUrl()` function now handles IPv6 and other previously unsupported URLs. Fixes issues [#89](https://github.com/apify/proxy-chain/issues/89) and [#67](https://github.com/apify/proxy-chain/issues/67). -0.4.7 / 2021-01-19 -=================== +# 0.4.7 / 2021-01-19 + - Bugfix: `closeTunnel()` function was returning invalid value. see PR [#98](https://github.com/apify/proxy-chain/pull/101). -0.4.6 / 2020-11-09 -=================== +# 0.4.6 / 2020-11-09 + - `Proxy.Server` now supports `port: 0` option to assign the port randomly, - see PR [#98](https://github.com/apify/proxy-chain/pull/98). + see PR [#98](https://github.com/apify/proxy-chain/pull/98). - `anonymizeProxy()` now uses the above port assignment rather than polling for random port => better performance - Updated NPM packages -0.4.5 / 2020-05-15 -=================== +# 0.4.5 / 2020-05-15 + - Added checks for closed handlers, in order to prevent the `Cannot read property 'pipe' of null` errors (see issue [#64](https://github.com/apify/proxy-chain/issues/64)) -0.4.4 / 2020-03-12 -=================== +# 0.4.4 / 2020-03-12 + - Attempt to fix an unhandled exception in `HandlerTunnelChain.onTrgRequestConnect` (see issue [#64](https://github.com/apify/proxy-chain/issues/64)) - Code cleanup -0.4.3 / 2020-03-08 -=================== +# 0.4.3 / 2020-03-08 + - Fixed unhandled `TypeError: Cannot read property '_httpMessage' of null` exception in `HandlerTunnelChain.onTrgRequestConnect` (see issue [#63](https://github.com/apify/proxy-chain/issues/63)) -0.4.2 / 2020-02-28 -=================== +# 0.4.2 / 2020-02-28 + - Bugfix: Prevented attempted double-sending of certain HTTP responses to client, which might have caused some esoteric errors - Error responses now by default have `Content-Type: text/plain; charset=utf-8` instead of `text/html; charset=utf-8` or missing one. -0.4.1 / 2020-02-22 -=================== +# 0.4.1 / 2020-02-22 + - Increased socket end/destroy timeouts from 100ms to 1000ms, to ensure the client receives the data. -0.4.0 / 2020-02-22 -=================== +# 0.4.0 / 2020-02-22 + - **BREAKING CHANGE**: Dropped support for Node.js 9 and lower. - BUGFIX: Consume source socket errors to avoid unhandled exceptions. Fixes [Issue #53](https://github.com/apify/proxy-chain/issues/53). @@ -97,17 +152,17 @@ - Fixed broken tests caused by newly introduced strict HTTP parsing in Node.js. - Fixed broken test on Node.js 10 by adding `NODE_OPTIONS=--insecure-http-parser` env var to `npm test`. -0.3.3 / 2019-12-27 -=================== +# 0.3.3 / 2019-12-27 + - More informative messages for "Invalid upstreamProxyUrl" errors -0.3.2 / 2019-09-17 -=================== +# 0.3.2 / 2019-09-17 + - Bugfix: Prevent the `"TypeError: hostHeader.startsWith is not a function` error in `HandlerForward` by not forwarding duplicate `Host` headers -0.3.1 / 2019-09-07 -=================== +# 0.3.1 / 2019-09-07 + - **BREAKING CHANGE**: `closeAnonymizedProxy` throws on invalid proxy URL - Bugfix: Attempt to prevent the unhandled "write after end" error - Bugfix: Proxy no longer attempts to forward invalid @@ -119,112 +174,112 @@ - Proxy source/target sockets are set to no delay (i.e. disabled Nagle's algorithm), to avoid any caching delays - Improved logging -0.2.7 / 2018-02-19 -=================== +# 0.2.7 / 2018-02-19 + - Updated README -0.2.6 / 2018-12-27 -=================== +# 0.2.6 / 2018-12-27 + - Bugfix: Added `Host` header to `HTTP CONNECT` requests to upstream proxies -0.2.5 / 2018-09-10 -=================== +# 0.2.5 / 2018-09-10 + - Bugfix: Invalid request headers broke proxy chain connection. Now they will be skipped instead. -0.2.4 / 2018-07-27 -=================== +# 0.2.4 / 2018-07-27 + - Bugfix: large custom responses were not delivered completely because the socket was closed too early -0.2.3 / 2018-06-21 -=================== +# 0.2.3 / 2018-06-21 + - Bugfix: 'requestFailed' was emitting `{ request, err }` instead of `{ request, error }` -0.2.2 / 2018-06-19 -=================== +# 0.2.2 / 2018-06-19 + - BREAKING: The 'requestFailed' event now emits object `{ request, error }` instead of just `error` -0.1.35 / 2018-06-12 -=================== +# 0.1.35 / 2018-06-12 + - Bugfix: When target URL cannot be parsed instead of crashing, throw RequestError -0.1.34 / 2018-06-08 -=================== +# 0.1.34 / 2018-06-08 + - Minor improvement: HandlerBase.fail() now supports RequestError -0.1.33 / 2018-06-08 -=================== +# 0.1.33 / 2018-06-08 + - Renamed `customResponseFunc` to `customResponseFunction` and changed parameters for more clarity -0.1.32 / 2018-06-08 -=================== +# 0.1.32 / 2018-06-08 + - Added `customResponseFunc` option to `prepareRequestFunction` to support custom response to HTTP requests -0.1.31 / 2018-05-21 -=================== +# 0.1.31 / 2018-05-21 + - Updated project homepage in package.json -0.1.29 / 2018-04-15 -=================== +# 0.1.29 / 2018-04-15 + - Fix: anonymizeProxy() now supports upstream proxies with empty password -0.1.28 / 2018-03-27 -=================== +# 0.1.28 / 2018-03-27 + - Added `createTunnel()` function to create tunnels through HTTP proxies for arbitrary TCP network connections (eq. connection to mongodb/sql database through HTTP proxy) -0.1.27 / 2018-03-05 -=================== +# 0.1.27 / 2018-03-05 + - Better error messages for common network errors - Pass headers from target socket in HTTPS tunnel chains -0.1.26 / 2018-02-14 -=================== +# 0.1.26 / 2018-02-14 + - If connection is denied because of authentication error, optionally "prepareRequestFunction" can provide error message. -0.1.25 / 2018-02-12 -=================== +# 0.1.25 / 2018-02-12 + - When connection is only through socket, close srcSocket when trgSocket ends -0.1.24 / 2018-02-09 -=================== +# 0.1.24 / 2018-02-09 + - Fixed incorrect closing of ServerResponse object which caused phantomjs to mark resource requests as errors. -0.1.23 / 2018-02-07 -=================== +# 0.1.23 / 2018-02-07 + - Fixed missing variable in "Incorrect protocol" error message. -0.1.22 / 2018-02-05 -=================== +# 0.1.22 / 2018-02-05 + - Renamed project's GitHub organization -0.1.21 / 2018-01-26 -=================== +# 0.1.21 / 2018-01-26 + - Added Server.getConnectionIds() function -0.1.20 / 2018-01-26 -=================== +# 0.1.20 / 2018-01-26 + - Fixed "TypeError: The header content contains invalid characters" bug -0.1.19 / 2018-01-25 -=================== +# 0.1.19 / 2018-01-25 + - fixed uncaught error events, code improved -0.1.18 / 2018-01-25 -=================== +# 0.1.18 / 2018-01-25 + - fixed a memory leak, improved logging and consolidated code -0.1.17 / 2018-01-23 -=================== +# 0.1.17 / 2018-01-23 + - added `connectionClosed` event to notify users about closed proxy connections -0.1.16 / 2018-01-09 -=================== +# 0.1.16 / 2018-01-09 + - added measuring of proxy stats - see `getConnectionStats()` function -0.1.14 / 2017-12-19 -=================== +# 0.1.14 / 2017-12-19 + - added support for multiple headers with the same name (thx shershennm) -0.0.1 / 2017-11-06 -=================== +# 0.0.1 / 2017-11-06 + - Project created diff --git a/README.md b/README.md index 29bf7e8d..de7809bd 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,55 @@ # Programmable HTTP proxy server for Node.js [![npm version](https://badge.fury.io/js/proxy-chain.svg)](http://badge.fury.io/js/proxy-chain) -[![Build Status](https://travis-ci.com/apify/proxy-chain.svg?branch=master)](https://travis-ci.com/apify/proxy-chain) -Node.js implementation of a proxy server (think Squid) with support for SSL, authentication, upstream proxy chaining, -custom HTTP responses and measuring traffic statistics. -The authentication and proxy chaining configuration is defined in code and can be dynamic. -Note that the proxy server only supports Basic authentication -(see [Proxy-Authorization](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Proxy-Authorization) for details). +A programmable proxy server (think Squid) with support for SSL/TLS, authentication, upstream proxy chaining, SOCKS4/5 protocol, +custom HTTP responses, and traffic statistics. +The authentication and proxy chaining configuration is defined in code and can be fully dynamic, giving you a high level of customization for your use case. -For example, this package is useful if you need to use proxies with authentication -in the headless Chrome web browser, because it doesn't accept proxy URLs such as `http://username:password@proxy.example.com:8080`. -With this library, you can set up a local proxy server without any password +For example, the proxy-chain package is useful if you need to use headless Chrome web browser and proxies with authentication, +because Chrome doesn't support proxy URLs with password, such as `http://username:password@proxy.example.com:8080`. +With this package, you can set up a local proxy server without any password that will forward requests to the upstream proxy with password. -The package is used for this exact purpose by the [Apify web scraping platform](https://www.apify.com). +For details, read [How to make headless Chrome and Puppeteer use a proxy server with authentication](https://blog.apify.com/how-to-make-headless-chrome-and-puppeteer-use-a-proxy-server-with-authentication-249a21a79212/). -To learn more about the rationale behind this package, -read [How to make headless Chrome and Puppeteer use a proxy server with authentication](https://medium.com/@jancurn/how-to-make-headless-chrome-and-puppeteer-use-a-proxy-server-with-authentication-249a21a79212). +The proxy-chain package is developed by [Apify](https://apify.com/), the full-stack web scraping and data extraction platform, to support their [Apify Proxy](https://apify.com/proxy) product, +which provides an easy access to a large pool of datacenter and residential IP addresses all around the world. The proxy-chain package is also used by [Crawlee](https://crawlee.dev/), +the world's most popular web craling library for Node.js. + +The proxy-chain package currently supports HTTP/SOCKS forwarding and HTTP CONNECT tunneling to forward arbitrary protocols such as HTTPS or FTP ([learn more](https://blog.apify.com/tunneling-arbitrary-protocols-over-http-proxy-with-static-ip-address-b3a2222191ff)). The HTTP CONNECT tunneling also supports the SOCKS protocol. Also, proxy-chain only supports the Basic [Proxy-Authorization](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Proxy-Authorization). ## Run a simple HTTP/HTTPS proxy server ```javascript -const ProxyChain = require('proxy-chain'); +import { Server } from 'proxy-chain'; -const server = new ProxyChain.Server({ port: 8000 }); +const server = new Server({ port: 8000 }); -server.listen(() => { - console.log(`Proxy server is listening on port ${8000}`); -}); +await server.listen(); +console.log(`Proxy server is listening on port ${server.port}`); ``` ## Run a HTTP/HTTPS proxy server with credentials and upstream proxy ```javascript -const ProxyChain = require('proxy-chain'); +import { Server } from 'proxy-chain'; -const server = new ProxyChain.Server({ +const server = new Server({ // Port where the server will listen. By default 8000. port: 8000, + // Optional host where the proxy server will listen. + // If not specified, the sever listens on an unspecified IP address (0.0.0.0 in IPv4, :: in IPv6) + // You can use this option to limit the access to the proxy server. + host: 'localhost', + // Enables verbose logging verbose: true, // Custom user-defined function to authenticate incoming proxy requests, // and optionally provide the URL to chained upstream proxy. // The function must return an object (or promise resolving to the object) with the following signature: - // { requestAuthentication: Boolean, upstreamProxyUrl: String } + // { requestAuthentication: boolean, upstreamProxyUrl: string, failMsg?: string, customTag?: unknown } // If the function is not defined or is null, the server runs in simple mode. // Note that the function takes a single argument with the following properties: // * request - An instance of http.IncomingMessage class with information about the client request @@ -63,24 +67,31 @@ const server = new ProxyChain.Server({ // requiring Basic authentication. Here you can verify user credentials. requestAuthentication: username !== 'bob' || password !== 'TopSecret', - // Sets up an upstream HTTP proxy to which all the requests are forwarded. + // Sets up an upstream HTTP/HTTPS/SOCKS proxy to which all the requests are forwarded. // If null, the proxy works in direct mode, i.e. the connection is forwarded directly // to the target server. This field is ignored if "requestAuthentication" is true. - // The username and password should be URI-encoded, in case it contains some special characters. - // See `parseUrl()` function for details. + // The username and password must be URI-encoded. upstreamProxyUrl: `http://username:password@proxy.example.com:3128`, + // Or use SOCKS4/5 proxy, e.g. + // upstreamProxyUrl: `socks://username:password@proxy.example.com:1080`, + + // Applies to HTTPS upstream proxy. If set to true, requests made to the proxy will + // ignore certificate errors. Useful when upstream proxy uses self-signed certificate. By default "false". + ignoreUpstreamProxyCertificate: true // If "requestAuthentication" is true, you can use the following property // to define a custom error message to return to the client instead of the default "Proxy credentials required" failMsg: 'Bad username or password, please try again.', + + // Optional custom tag that will be passed back via + // `tunnelConnectResponded` or `tunnelConnectFailed` events + // Can be used to pass information between proxy-chain + // and any external code or application using it + customTag: { userId: '123' }, }; }, }); -server.listen(() => { - console.log(`Proxy server is listening on port ${server.port}`); -}); - // Emitted when HTTP connection is closed server.on('connectionClosed', ({ connectionId, stats }) => { console.log(`Connection ${connectionId} closed`); @@ -92,8 +103,201 @@ server.on('requestFailed', ({ request, error }) => { console.log(`Request ${request.url} failed`); console.error(error); }); + +await server.listen(); +console.log(`Proxy server is listening on port ${server.port}`); +``` + +## Run a simple HTTPS proxy server + +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 +import { Server } from 'proxy-chain'; +import fs from 'node:fs'; +import path from 'node:path'; + +(async () => { + // TODO: update these lines to use your own key and cert + const sslKey = fs.readFileSync(path.join(import.meta.dirname, 'ssl.key')); + const sslCrt = fs.readFileSync(path.join(import.meta.dirname, 'ssl.crt')); + + const server = new Server({ + // Main difference between 'http' and 'https' is additional event listening: + // + // http + // -> listen for 'connection' events to track raw TCP sockets + // + // https: + // -> listen for 'securedConnection' events (instead of 'connection') to track only post-TLS-handshake sockets + // -> additionally listen for 'tlsError' events to handle TLS handshake errors + // + // Default value is 'http' + serverType: 'https', + + // Provide the TLS certificate and private key + httpsOptions: { + key: sslKey, + cert: sslCrt, + }, + + // Port where the server will listen + port: 8443, + + // Enable verbose logging to see what's happening + verbose: true, + + // Optional: Add authentication and upstream proxy configuration + prepareRequestFunction: ({ username, hostname, port }) => { + console.log(`Request to ${hostname}:${port} from user: ${username || 'anonymous'}`); + + // Allow the request + return {}; + }, + }); + + // Handle failed HTTP/HTTPS requests + server.on('requestFailed', ({ request, error }) => { + console.log(`Request ${request.url} failed`); + console.error(error); + }); + + // Handle TLS handshake errors + server.on('tlsError', ({ error, socket }) => { + console.error(`TLS error from ${socket.remoteAddress}: ${error.message}`); + }); + + // Emitted when HTTP/HTTPS connection is closed + server.on('connectionClosed', ({ connectionId, stats }) => { + console.log(`Connection ${connectionId} closed`); + console.dir(stats); + }); + + // Start the server + await server.listen(); + + // Handle graceful shutdown + process.on('SIGINT', async () => { + console.log('\nShutting down server...'); + await server.close(true); + console.log('Server closed.'); + process.exit(0); + }); + + // Keep the server running + await new Promise(() => { }); +})(); +``` + +Run server: + +```bash +node https_proxy_server.js ``` +Send request via proxy: + +```bash +curl --proxy-insecure -x https://localhost:8443 -k https://example.com +``` + +## Use custom HTTP agents for connection pooling + +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 +import http from 'node:http'; +import https from 'node:https'; +import { Server } from 'proxy-chain'; + +// Create agents with keepAlive to enable connection pooling +const httpAgent = new http.Agent({ + keepAlive: true, + maxSockets: 10, +}); + +const httpsAgent = new https.Agent({ + keepAlive: true, + maxSockets: 10, +}); + +const server = new Server({ + port: 8000, + prepareRequestFunction: ({ request }) => { + return { + upstreamProxyUrl: 'http://proxy.example.com:8080', + // Or for HTTPS upstream proxy: 'https://proxy.example.com:8080' + + // Agents enable connection pooling to upstream proxy + httpAgent, // Used for HTTP upstream proxies + httpsAgent, // Used for HTTPS upstream proxies + }; + }, +}); + +await server.listen(); +console.log(`Proxy server is listening on port ${server.port}`); +``` + +**Note:** Custom agents are only supported for HTTP and HTTPS upstream proxies. SOCKS upstream proxies use direct socket connections and do not support custom agents. + +## SOCKS support +SOCKS protocol is supported for versions 4 and 5, specifically: `['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']`, where `socks` will default to version 5. + +You can use an `upstreamProxyUrl` like `socks://username:password@proxy.example.com:1080`. + +## Error status codes + +The `502 Bad Gateway` HTTP status code is not comprehensive enough. Therefore, the server may respond with `590-599` instead: + +### `590 Non Successful` + +Upstream responded with non-200 status code. + +### `591 RESERVED` + +*This status code is reserved for further use.* + +### `592 Status Code Out Of Range` + +Upstream respondend with status code different than 100-999. + +### `593 Not Found` + +DNS lookup failed - [`EAI_NODATA`](https://github.com/libuv/libuv/blob/cdbba74d7a756587a696fb3545051f9a525b85ac/include/uv.h#L82) or [`EAI_NONAME`](https://github.com/libuv/libuv/blob/cdbba74d7a756587a696fb3545051f9a525b85ac/include/uv.h#L83). + +### `594 Connection Refused` + +Upstream refused connection. + +### `595 Connection Reset` + +Connection reset due to loss of connection or timeout. + +### `596 Broken Pipe` + +Trying to write on a closed socket. + +### `597 Auth Failed` + +Incorrect upstream credentials. + +### `598 RESERVED` + +*This status code is reserved for further use.* + +### `599 Upstream Error` + +Generic upstream error. + +--- + +`590` and `592` indicate an issue on the upstream side. \ +`593` indicates an incorrect `proxy-chain` configuration.\ +`594`, `595` and `596` may occur due to connection loss.\ +`597` indicates incorrect upstream credentials.\ +`599` is a generic error, where the above is not applicable. + ## Custom error responses To return a custom HTTP response to indicate an error to the client, @@ -102,12 +306,12 @@ 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 { Server, RequestError } from 'proxy-chain'; -const server = new ProxyChain.Server({ +const server = new Server({ prepareRequestFunction: ({ request, username, password, hostname, port, isHttp, connectionId }) => { if (username !== 'bob') { - throw new ProxyChain.RequestError('Only Bob can use this proxy!', 400); + throw new RequestError('Only Bob can use this proxy!', 400); } }, }); @@ -172,9 +376,9 @@ with the following properties: Here is a simple example: ```javascript -const ProxyChain = require('proxy-chain'); +import { Server } from 'proxy-chain'; -const server = new ProxyChain.Server({ +const server = new Server({ port: 8000, prepareRequestFunction: ({ request, username, password, hostname, port, isHttp }) => { return { @@ -188,24 +392,72 @@ const server = new ProxyChain.Server({ }, }); -server.listen(() => { - console.log(`Proxy server is listening on port ${server.port}`); +await server.listen(); +console.log(`Proxy server is listening on port ${server.port}`); +``` + +## Routing CONNECT to another HTTP server + +While `customResponseFunction` enables custom handling methods such as `GET` and `POST`, many HTTP clients rely on `CONNECT` tunnels. +It's possible to route those requests differently using the `customConnectServer` option. It accepts an instance of Node.js HTTP server. + +```javascript +import http from 'node:http'; +import { Server } from 'proxy-chain'; + +const exampleServer = http.createServer((request, response) => { + response.end('Hello from a custom server!'); }); + +const server = new Server({ + port: 8000, + prepareRequestFunction: ({ request, username, password, hostname, port, isHttp }) => { + if (request.url.toLowerCase() === 'example.com:80') { + return { + customConnectServer: exampleServer, + }; + } + + return {}; + }, +}); + +await server.listen(); +console.log(`Proxy server is listening on port ${server.port}`); ``` -## Closing the server +In the example above, all CONNECT tunnels to `example.com` are overridden. +This is an unsecure server, so it accepts only `http:` requests. -To shut down the proxy server, call the `close([destroyConnections], [callback])` function. For example: +In order to intercept `https:` requests, `https.createServer` should be used instead, along with a self signed certificate. ```javascript -server.close(true, () => { - console.log('Proxy server was closed.'); +import https from 'node:https'; +import fs from 'node:fs'; + +const key = fs.readFileSync('./test/ssl.key'); +const cert = fs.readFileSync('./test/ssl.crt'); + +const exampleServer = https.createServer({ + key, + cert, +}, (request, response) => { + response.end('Hello from a custom server!'); }); ``` +## Closing the server + +To shut down the proxy server, call the `close([destroyConnections])` function. For example: + +```javascript +await server.close(true); +console.log('Proxy server was closed.'); +``` + The `closeConnections` parameter indicates whether pending proxy connections should be forcibly closed. If it's `false`, the function will wait until all connections are closed, which can take a long time. -If the `callback` parameter is omitted, the function returns a promise. +The function returns a promise. ## Accessing the CONNECT response headers for proxy tunneling @@ -220,7 +472,7 @@ the parameter types of the event callback are described in [Node.js's documentat [1]: https://nodejs.org/api/http.html#http_event_connect ```javascript -server.on('tunnelConnectResponded', ({ response, socket, head }) => { +server.on('tunnelConnectResponded', ({ proxyChainId, response, socket, head, customTag }) => { console.log(`CONNECT response headers received: ${response.headers}`); }); ``` @@ -233,33 +485,43 @@ listenConnectAnonymizedProxy(anonymizedProxyUrl, ({ response, socket, head }) => }); ``` +You can also listen to CONNECT requests that receive response with status code different from 200. +The proxy server would emit a `tunnelConnectFailed` event. + +```javascript +server.on('tunnelConnectFailed', ({ proxyChainId, response, socket, head, customTag }) => { + console.log(`CONNECT response failed with status code: ${response.statusCode}`); +}); +``` ## Helper functions The package also provides several utility functions. -### `anonymizeProxy(proxyUrl, callback)` +### `anonymizeProxy({ url, port })` -Parses and validates a HTTP proxy URL. If the proxy requires authentication, +Parses and validates a HTTP/HTTPS proxy URL. If the proxy requires authentication, then the function starts an open local proxy server that forwards to the proxy. -The port is chosen randomly. +The port (on which the local proxy server will start) can be set via the `port` property of the first argument, if not provided, it will be chosen randomly. -The function takes an optional callback that receives the anonymous proxy URL. -If no callback is supplied, the function returns a promise that resolves to a String with -anonymous proxy URL or the original URL if it was already anonymous. +For HTTPS proxy with self-signed certificate, set `ignoreProxyCertificate` property of the first argument to `true` to ignore certificate errors in +proxy requests. + +The function returns a promise that resolves to a String with the anonymous proxy URL, +or the original URL if it was already anonymous. The following example shows how you can use a proxy with authentication 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 { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain'; (async() => { const oldProxyUrl = 'http://bob:password123@proxy.example.com:8000'; - const newProxyUrl = await proxyChain.anonymizeProxy(oldProxyUrl); + const newProxyUrl = await anonymizeProxy(oldProxyUrl); // Prints something like "http://127.0.0.1:45678" console.log(newProxyUrl); @@ -275,11 +537,11 @@ const proxyChain = require('proxy-chain'); await browser.close(); // Clean up - await proxyChain.closeAnonymizedProxy(newProxyUrl, true); + await closeAnonymizedProxy(newProxyUrl, true); })(); ``` -### `closeAnonymizedProxy(anonymizedProxyUrl, closeConnections, callback)` +### `closeAnonymizedProxy(anonymizedProxyUrl, closeConnections)` Closes anonymous proxy previously started by `anonymizeProxy()`. If proxy was not found or was already closed, the function has no effect @@ -288,18 +550,18 @@ and its result is `false`. Otherwise the result is `true`. The `closeConnections` parameter indicates whether pending proxy connections are forcibly closed. If it's `false`, the function will wait until all connections are closed, which can take a long time. -The function takes an optional callback that receives the result Boolean from the function. -If callback is not provided, the function returns a promise instead. +The function returns a promise that resolves to a Boolean. -### `createTunnel(proxyUrl, targetHost, options, callback)` +### `createTunnel(proxyUrl, targetHost, options)` -Creates a TCP tunnel to `targetHost` that goes through a HTTP proxy server +Creates a TCP tunnel to `targetHost` that goes through a HTTP/HTTPS proxy server specified by the `proxyUrl` parameter. The optional `options` parameter is an object with the following properties: - `port: Number` - Enables specifying the local port to listen at. By default `0`, which means a random port will be selected. - `hostname: String` - Local hostname to listen at. By default `localhost`. +- `ignoreProxyCertificate` - For HTTPS proxy, ignore certificate errors in proxy requests. Useful for proxy with self-signed certificate. By default `false`. - `verbose: Boolean` - If `true`, the functions logs a lot. By default `false`. The result of the function is a local endpoint in a form of `hostname:port`. @@ -308,8 +570,7 @@ For example, this is useful if you want to access a certain service from a speci The tunnel should be eventually closed by calling the `closeTunnel()` function. -The `createTunnel()` function accepts an optional Node.js-style callback that receives the path to the local endpoint. -If no callback is supplied, the function returns a promise that resolves to a String with +The `createTunnel()` function returns a promise that resolves to a String with the path to the local endpoint. For more information, read this [blog post](https://blog.apify.com/tunneling-arbitrary-protocols-over-http-proxy-with-static-ip-address-b3a2222191ff). @@ -322,7 +583,7 @@ const host = await createTunnel('http://bob:pass123@proxy.example.com:8000', 'se console.log(host); ``` -### `closeTunnel(tunnelString, closeConnections, callback)` +### `closeTunnel(tunnelString, closeConnections)` Closes tunnel previously started by `createTunnel()`. The result value is `false` if the tunnel was not found or was already closed, otherwise it is `true`. @@ -330,8 +591,7 @@ The result value is `false` if the tunnel was not found or was already closed, o The `closeConnections` parameter indicates whether pending connections are forcibly closed. If it's `false`, the function will wait until all connections are closed, which can take a long time. -The function takes an optional callback that receives the result of the function. -If the callback is not provided, the function returns a promise instead. +The function returns a promise that resolves to a Boolean. ### `listenConnectAnonymizedProxy(anonymizedProxyUrl, tunnelConnectRespondedCallback)` @@ -339,34 +599,6 @@ Allows to configure a callback on the anonymized proxy URL for the CONNECT respo above section [Accessing the CONNECT response headers for proxy tunneling](#accessing-the-connect-response-headers-for-proxy-tunneling) for details. -### `parseUrl(url)` - -An utility function for parsing URLs. -It parses the URL using Node.js' `new URL(url)` and adds the following features: - -- The result is a vanilla JavaScript object -- `port` field is casted to number / null from string -- `path` field is added (pathname + search) -- both username and password is URI-decoded if possible - (if not, the function keeps the username and password as is) -- `auth` field is added, and it contains username + ":" + password, or an empty string. - -Note that `port` is returned even if it is a default port for `http(s)` and few other protocols. This differs from `new URL(url)` where port is null when default. - -If the URL is invalid, the function throws an error. - -The username and password parsing should make it possible to parse proxy URLs containing -special characters, such as `http://user:pass:wrd@proxy.example.com` -or `http://us%35er:passwrd@proxy.example.com`. The parsing is done on a best-effort basis. -The safest way is to always URI-encode username and password before constructing -the URL, according to RFC 3986. - -Note that compared to the old implementation using `url.parse()`, the new function: - - - is unable to distinguish empty password and missing password - - password and username are empty string if not present (or empty) - - we are able to parse IPv6 - ### `redactUrl(url, passwordReplacement)` Takes a URL and hides the password from it. For example: diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..257cac34 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,20 @@ +import apifyTypescriptConfig from '@apify/eslint-config/ts.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + { ignores: ['**/dist', 'test'] }, // Ignores need to happen first + ...apifyTypescriptConfig, + { + languageOptions: { + sourceType: 'module', + + parserOptions: { + project: 'tsconfig.eslint.json', + }, + }, + rules: { + 'no-param-reassign': 'off', + 'import/extensions': 'off', + }, + }, +]; diff --git a/examples/apify_proxy_tunnel.js b/examples/apify_proxy_tunnel.js index 1c2fbc7f..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 @@ -20,13 +20,15 @@ const { createTunnel, closeTunnel, redactUrl } = require('proxy-chain'); // The "verbose" option causes a lot of logging const tunnelInfo = await createTunnel(PROXY_URL, TARGET_HOST, { port: 9999, verbose: true }); + // eslint-disable-next-line no-console console.log(`Tunnel to ${TARGET_HOST} via ${redactUrl(PROXY_URL)} established at ${tunnelInfo}...`); // Here we assume por 443 from above, otherwise the service will not be accessible via HTTPS! + // eslint-disable-next-line no-console console.log(`To test it, you can run: curl --verbose https://${tunnelInfo}`); // Wait forever... - await new Promise(() => {}); + await new Promise(() => { }); // Normally, you'd also want to close the tunnel and all open connections await closeTunnel(tunnelInfo, true); diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 00000000..dbb5dc79 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,22 @@ +import type { Config } from '@jest/types'; + +// eslint-disable-next-line import/no-default-export +export default (): Config.InitialOptions => ({ + verbose: true, + preset: 'ts-jest', + testEnvironment: 'node', + testRunner: 'jest-circus/runner', + testTimeout: 20_000, + collectCoverage: true, + collectCoverageFrom: [ + '**/src/**/*.ts', + '**/src/**/*.js', + '!**/node_modules/**', + ], + maxWorkers: 3, + globals: { + 'ts-jest': { + tsconfig: '/test/tsconfig.json', + }, + }, +}); diff --git a/package.json b/package.json index f210e5b1..42596f11 100644 --- a/package.json +++ b/package.json @@ -1,79 +1,116 @@ { - "name": "proxy-chain", - "version": "1.0.2", - "description": "Node.js implementation of a proxy server (think Squid) with support for SSL, authentication, upstream proxy chaining, and protocol tunneling.", - "main": "build/index.js", - "keywords": [ - "proxy", - "squid", - "apify", - "tunnel", - "puppeteer" - ], - "author": { - "name": "Apify Technologies", - "email": "support@apify.com", - "url": "https://apify.com" - }, - "contributors": [ - "Jan Curn " - ], - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "git+https://github.com/apify/proxy-chain" - }, - "bugs": { - "url": "https://github.com/apify/proxy-chain/issues" - }, - "homepage": "https://blog.apify.com/how-to-make-headless-chrome-and-puppeteer-use-a-proxy-server-with-authentication-249a21a79212", - "files": [ - "build" - ], - "scripts": { - "build": "rm -rf ./build && babel src --out-dir build", - "local-proxy": "npm run build && node ./build/run_locally.js", - "test": "npm run build && cross-env NODE_OPTIONS=--insecure-http-parser mocha --exit --recursive", - "prepare": "npm run build", - "clean": "rm -rf build", - "lint": "npm run build && eslint src", - "lint-fix": "npm run build && eslint src --fix" - }, - "engines": { - "node": ">=10" - }, - "dependencies": { - "underscore": "^1.9.1" - }, - "devDependencies": { - "babel-cli": "^6.6.5", - "babel-core": "^6.1.21", - "babel-eslint": "^10.0.3", - "babel-preset-es2015": "^6.1.18", - "babel-preset-stage-0": "^6.1.18", - "basic-auth": "^2.0.0", - "body-parser": "^1.18.2", - "chai": "^4.3.0", - "cross-env": "^7.0.3", - "eslint": "^7.19.0", - "eslint-config-airbnb": "^18.0.1", - "eslint-config-airbnb-base": "^14.0.0", - "eslint-plugin-import": "^2.2.0", - "eslint-plugin-jsx-a11y": "^6.2.3", - "eslint-plugin-promise": "^4.2.1", - "eslint-plugin-react": "^7.0.1", - "express": "^4.16.2", - "faye-websocket": "^0.11.3", - "isparta": "^4.0.0", - "mocha": "^9.0.3", - "phantomjs-prebuilt": "^2.1.16", - "portastic": "^1.0.1", - "proxy": "^1.0.1", - "request": "^2.83.0", - "sinon": "^11.1.2", - "sinon-stub-promise": "^4.0.0", - "through": "^2.3.8", - "ws": "^8.0.0" - }, - "optionalDependencies": {} + "name": "proxy-chain", + "version": "3.0.1", + "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", + "apify", + "tunnel", + "puppeteer" + ], + "author": { + "name": "Apify Technologies", + "email": "support@apify.com", + "url": "https://apify.com" + }, + "contributors": [ + "Jan Curn " + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/apify/proxy-chain" + }, + "bugs": { + "url": "https://github.com/apify/proxy-chain/issues" + }, + "homepage": "https://blog.apify.com/how-to-make-headless-chrome-and-puppeteer-use-a-proxy-server-with-authentication-249a21a79212", + "files": [ + "dist", + "!**/*.tsbuildinfo" + ], + "scripts": { + "build:watch": "tsc -w", + "build": "tsc", + "clean": "rimraf dist", + "prepublishOnly": "pnpm run build", + "local-proxy": "tsx test/utils/run_locally.js", + "test": "nyc cross-env NODE_OPTIONS=--insecure-http-parser mocha 'test/unit/**/*.js' 'test/e2e/**/*.js'", + "test:unit": "mocha 'test/unit/**/*.js'", + "test:e2e": "nyc cross-env NODE_OPTIONS=--insecure-http-parser mocha 'test/e2e/**/*.js'", + "test:bun": "bun --bun run mocha --no-config --exit 'test/unit/**/*.js'", + "test:bun:e2e:compatible": "bun --bun run mocha --no-config --exit --grep 'throws error' test/e2e/tcp_tunnel.js", + "test:bun:e2e:full": "bun --bun run mocha --no-config --exit 'test/e2e/**/*.js'", + "test:docker": "docker build --tag proxy-chain-tests --file test/Dockerfile . && docker run --add-host localhost-test:127.0.0.1 proxy-chain-tests", + "test:docker:all": "bash scripts/test-docker-all.sh", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "engines": { + "node": ">=20.11" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.4", + "onFail": "warn" + } + }, + "packageManager": "pnpm@10.33.4", + "devDependencies": { + "@apify/eslint-config": "^1.0.0", + "@apify/tsconfig": "^0.1.0", + "@types/jest": "^28.1.2", + "@types/node": "^20.19.29", + "basic-auth": "^2.0.1", + "basic-auth-parser": "^0.0.2", + "body-parser": "^1.19.0", + "chai": "^4.3.4", + "cross-env": "^7.0.3", + "eslint": "^9.18.0", + "express": "^4.17.1", + "faye-websocket": "^0.11.4", + "got-scraping": "^3.2.4-beta.0", + "isparta": "^4.1.1", + "mocha": "^10.0.0", + "nyc": "^15.1.0", + "portastic": "^1.0.1", + "proxy": "^1.0.2", + "puppeteer": "^25.1.0", + "request": "^2.88.2", + "rimraf": "^4.1.2", + "sinon": "^13.0.2", + "sinon-stub-promise": "^4.0.0", + "socksv5": "^0.0.6", + "through": "^2.3.8", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.20.0", + "underscore": "^1.13.1", + "ws": "^8.2.2" + }, + "nyc": { + "reporter": [ + "text", + "html", + "lcov" + ], + "exclude": [ + "**/test/**" + ] + }, + "dependencies": { + "socks": "^2.8.3", + "socks-proxy-agent": "^8.0.3", + "tslib": "^2.3.1" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..449e545a --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,6214 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + form-data: 2.5.4 + +importers: + + .: + dependencies: + socks: + specifier: ^2.8.3 + version: 2.8.9 + socks-proxy-agent: + specifier: ^8.0.3 + version: 8.0.5 + tslib: + specifier: ^2.3.1 + version: 2.8.1 + devDependencies: + '@apify/eslint-config': + specifier: ^1.0.0 + version: 1.2.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript-eslint@8.59.3(eslint@9.39.4)(typescript@5.9.3)) + '@apify/tsconfig': + specifier: ^0.1.0 + version: 0.1.2 + '@types/jest': + specifier: ^28.1.2 + version: 28.1.8 + '@types/node': + specifier: ^20.19.29 + version: 20.19.41 + basic-auth: + specifier: ^2.0.1 + version: 2.0.1 + basic-auth-parser: + specifier: ^0.0.2 + version: 0.0.2 + body-parser: + specifier: ^1.19.0 + version: 1.20.5 + chai: + specifier: ^4.3.4 + version: 4.5.0 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + eslint: + specifier: ^9.18.0 + version: 9.39.4 + express: + specifier: ^4.17.1 + version: 4.22.2 + faye-websocket: + specifier: ^0.11.4 + version: 0.11.4 + got-scraping: + specifier: ^3.2.4-beta.0 + version: 3.2.15 + isparta: + specifier: ^4.1.1 + version: 4.1.1 + mocha: + specifier: ^10.0.0 + version: 10.8.2 + nyc: + specifier: ^15.1.0 + version: 15.1.0 + portastic: + specifier: ^1.0.1 + version: 1.0.1 + proxy: + specifier: ^1.0.2 + version: 1.0.2 + puppeteer: + specifier: ^25.1.0 + version: 25.1.0 + request: + specifier: ^2.88.2 + version: 2.88.2 + rimraf: + specifier: ^4.1.2 + version: 4.4.1 + sinon: + specifier: ^13.0.2 + version: 13.0.2 + sinon-stub-promise: + specifier: ^4.0.0 + version: 4.0.0 + socksv5: + specifier: ^0.0.6 + version: 0.0.6 + through: + specifier: ^2.3.8 + version: 2.3.8 + tsx: + specifier: ^4.21.0 + version: 4.22.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.20.0 + version: 8.59.3(eslint@9.39.4)(typescript@5.9.3) + underscore: + specifier: ^1.13.1 + version: 1.13.8 + ws: + specifier: ^8.2.2 + version: 8.20.1 + +packages: + + '@apify/eslint-config@1.2.0': + resolution: {integrity: sha512-zgWBdv62ajaPBxc0pDIdxfVKqA8pOf1fuly5kt5a1y7P16t9NkfGlvaRl0BqXqv06VxLFVagSK0iQrHh2qMTEA==} + peerDependencies: + '@vitest/eslint-plugin': ^1.6.14 + eslint: ^9.19.0 + eslint-plugin-jest: ^28.11.0 + typescript-eslint: ^8.23.0 + peerDependenciesMeta: + '@vitest/eslint-plugin': + optional: true + eslint-plugin-jest: + optional: true + typescript-eslint: + optional: true + + '@apify/tsconfig@0.1.2': + resolution: {integrity: sha512-9dzEI1ZQ5+iM0k0fmPJrpdSSPUolVdeI1nDGFZMjD9UabTmIvjQrzui+1a25uy913AUEBrKTojEPj87pU9/Ekg==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/compat@1.4.1': + resolution: {integrity: sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.40 || 9 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/expect-utils@28.1.3': + resolution: {integrity: sha512-wvbi9LUrHJLn3NlDW6wF2hvIMtd4JUl2QNVrjq+IBSHirgfrR3o9RnVtxzdEGO2n9JyIWwHnLfby5KzqBGg2YA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + + '@jest/schemas@28.1.3': + resolution: {integrity: sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + + '@jest/types@28.1.3': + resolution: {integrity: sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@puppeteer/browsers@3.0.4': + resolution: {integrity: sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + proxy-agent: '>=8.0.1' + peerDependenciesMeta: + proxy-agent: + optional: true + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@sinclair/typebox@0.24.51': + resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==} + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@sinonjs/commons@1.8.6': + resolution: {integrity: sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@11.3.1': + resolution: {integrity: sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==} + + '@sinonjs/fake-timers@9.1.2': + resolution: {integrity: sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==} + + '@sinonjs/samsam@6.1.3': + resolution: {integrity: sha512-nhOb2dWPeb1sd3IQXL/dVPnKHDOAFfvichtBf4xV00/rU1QbPCQqKMbvIheIjqwVjh7qIgf2AHTHi391yMOMpQ==} + + '@sinonjs/text-encoding@0.7.3': + resolution: {integrity: sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==} + deprecated: |- + Deprecated: no longer maintained and no longer used by Sinon packages. See + https://github.com/sinonjs/nise/issues/243 for replacement details. + + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@28.1.8': + resolution: {integrity: sha512-8TJkV++s7B6XqnDrzR1m/TT0A0h948Pnl/097veySPN67VRAgQ4gZ7n2KfJo2rVq6njQjdxU3GCCyDvAeuHoiw==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@20.19.41': + resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + + '@types/responselike@1.0.0': + resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typescript-eslint/eslint-plugin@8.59.3': + resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.3 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.3': + resolution: {integrity: sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.3': + resolution: {integrity: sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.3': + resolution: {integrity: sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.3': + resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.3': + resolution: {integrity: sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.3': + resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.3': + resolution: {integrity: sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.3': + resolution: {integrity: sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.3': + resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + abbrev@1.0.9: + resolution: {integrity: sha512-LEyx4aLEC3x6T0UguF6YILf+ntvmOaWsVfENmIW0E9H09vKlLDGelMjjSm0jkDHALj8A8quZ/HapKNigzwge+Q==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + adm-zip@0.5.17: + resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} + engines: {node: '>=12.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + amdefine@1.0.1: + resolution: {integrity: sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==} + engines: {node: '>=0.4.2'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@2.1.1: + resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} + engines: {node: '>=0.10.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@1.0.0: + resolution: {integrity: sha512-3iF4FIKdxaVYT3JqQuY3Wat/T2t7TRbbQ94Fu50ZUCbLy4TFbTzr90NOHQodQkNqmeEGCw8WbeP78WNi6SKYUA==} + engines: {node: '>=0.8.0'} + + ansi-styles@2.2.1: + resolution: {integrity: sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==} + engines: {node: '>=0.10.0'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-transform@2.0.0: + resolution: {integrity: sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==} + engines: {node: '>=8'} + + archy@1.0.0: + resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + args@5.0.1: + resolution: {integrity: sha512-1kqmFCFsPffavQFGt8OxJdIcETti99kySRUPMpOhaGjL6mRJn8HFU1OxKY5bMqfZKUwTQc1mZkAjmGYaVOHFtQ==} + engines: {node: '>= 6.0.0'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async@1.5.2: + resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + + babel-code-frame@6.26.0: + resolution: {integrity: sha512-XqYMR2dfdGMW+hd0IUZ2PwK+fGeFkOxZJ0wY+JaQAHzt1Zx8LcvpiZD2NiGkEG8qx0CfkAOr5xt76d1e8vG90g==} + + babel-core@6.26.3: + resolution: {integrity: sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA==} + + babel-generator@6.26.1: + resolution: {integrity: sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==} + + babel-helpers@6.24.1: + resolution: {integrity: sha512-n7pFrqQm44TCYvrCDb0MqabAF+JUBq+ijBvNMUxpkLjJaAu32faIexewMumrH5KLLJ1HDyT0PTEqRyAe/GwwuQ==} + + babel-messages@6.23.0: + resolution: {integrity: sha512-Bl3ZiA+LjqaMtNYopA9TYE9HP1tQ+E5dLxE0XrAzcIJeK2UqF0/EaqXwBn9esd4UmTfEab+P+UYQ1GnioFIb/w==} + + babel-register@6.26.0: + resolution: {integrity: sha512-veliHlHX06wjaeY8xNITbveXSiI+ASFnOqvne/LaIJIqOWi2Ogmj91KOugEz/hoh/fwMhXNBJPCv8Xaz5CyM4A==} + + babel-runtime@6.26.0: + resolution: {integrity: sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==} + + babel-template@6.26.0: + resolution: {integrity: sha512-PCOcLFW7/eazGUKIoqH97sO9A2UYMahsn/yRQ7uOk37iutwjq7ODtcTNF+iFDSHNfkctqsLRjLP7URnOx0T1fg==} + + babel-traverse@6.26.0: + resolution: {integrity: sha512-iSxeXx7apsjCHe9c7n8VtRXGzI2Bk1rBSOJgCCjfyXb6v1aCqE1KSEpq/8SXuVN8Ka/Rh1WDTF0MDzkvTA4MIA==} + + babel-types@6.26.0: + resolution: {integrity: sha512-zhe3V/26rCWsEZK8kZN+HaQj5yQ1CilTObixFzKW1UWjqG7618Twz6YEsCnjfg5gBcJh02DrpCkS9h98ZqDY+g==} + + babylon@6.18.0: + resolution: {integrity: sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==} + hasBin: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.30: + resolution: {integrity: sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==} + engines: {node: '>=6.0.0'} + hasBin: true + + basic-auth-parser@0.0.2: + resolution: {integrity: sha512-Y7OBvWn+JnW45JWHLY6ybYub2k9cXCMrtCyO1Hds2s6eqClqWhPnOQpgXUPjAiMHj+A8TEPIQQ1dYENnJoBOHQ==} + + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bluebird@2.11.0: + resolution: {integrity: sha512-UfFSr22dmHPQqPP9XWHRhq+gWnHCYguQGkXQlbyPtW5qTnhFWA8/iXg765tH0cAjy7l/zPJ1aBTO0g5XgA7kvQ==} + + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-stdout@1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cacheable-lookup@6.1.0: + resolution: {integrity: sha512-KJ/Dmo1lDDhmW2XDPMo+9oiy/CeqosPguPCrgcVzKyZrL6pM1gU2GmPY/xo6OQPTUaA/c0kwHuywB4E6nmT9ww==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.2: + resolution: {integrity: sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==} + engines: {node: '>=8'} + + caching-transform@4.0.0: + resolution: {integrity: sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.0.0: + resolution: {integrity: sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + chalk@0.4.0: + resolution: {integrity: sha512-sQfYDlfv2DGVtjdoQqxS0cEZDroyG8h6TamA6rvxwlrU5BaSLDx9xhatBYl2pxZ7gmpNaPFVwBtdGdu5rQ+tYQ==} + engines: {node: '>=0.8.0'} + + chalk@1.1.3: + resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} + engines: {node: '>=0.10.0'} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chromium-bidi@16.0.1: + resolution: {integrity: sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0'} + peerDependencies: + devtools-protocol: '*' + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confusing-browser-globals@1.0.11: + resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-js@2.6.12: + resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + decamelize@4.0.0: + resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} + engines: {node: '>=10'} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + default-require-extensions@3.0.1: + resolution: {integrity: sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==} + engines: {node: '>=8'} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-indent@4.0.0: + resolution: {integrity: sha512-BDKtmHlOzwI7iRuEkhzsnPoi5ypEhWAJB5RvHWe1kMr06js3uK5B3734i3ui5Yd+wOJV1cpE4JnivPD283GU/A==} + engines: {node: '>=0.10.0'} + + devtools-protocol@0.0.1624250: + resolution: {integrity: sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==} + + diff-sequences@28.1.1: + resolution: {integrity: sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.358: + resolution: {integrity: sha512-EO7tKm3QxRqTs1lSuPXzl6yRAwznehp0AH9OoMOIC+4mQzTFday8FJCO5KU6J/TFSQXEOahNq4vTKpz1jmCVOA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@1.14.3: + resolution: {integrity: sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==} + engines: {node: '>=4.0'} + hasBin: true + + escodegen@1.8.1: + resolution: {integrity: sha512-yhi5S+mNTOuRvyW4gWlg5W1byMaQGWWSYHXsuFZ7GBo7tpyOwi2EdzMP/QWxh9hwkD2m+wDVHJsxhRIj+v/b/A==} + engines: {node: '>=0.12.0'} + hasBin: true + + eslint-config-airbnb-base@15.0.0: + resolution: {integrity: sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==} + engines: {node: ^10.12.0 || >=12.0.0} + peerDependencies: + eslint: ^7.32.0 || ^8.2.0 + eslint-plugin-import: ^2.25.2 + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-simple-import-sort@12.1.1: + resolution: {integrity: sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==} + peerDependencies: + eslint: '>=5.0.0' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@2.7.3: + resolution: {integrity: sha512-OarPfz0lFCiW4/AV2Oy1Rp9qu0iusTKqykwTspGCZtPxmF81JR4MmIebvF1F9+UOKth2ZubLQ4XGGaU+hSn99A==} + engines: {node: '>=0.10.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@1.9.3: + resolution: {integrity: sha512-25w1fMXQrGdoquWnScXZGckOv+Wes+JDnuN/+7ex3SauFRS72r2lFDec0EKPt2YD1wUJ/IrfEex+9yp4hfSOJA==} + engines: {node: '>=0.10.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + expect@28.1.3: + resolution: {integrity: sha512-eEh0xn8HlsuOBxFgIss+2mX85VAS4Qy3OSkjV7rlBWljtA4oWH37glVGyOZSZvErDT/yBywZdPGwCXuTvSG85g==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + faye-websocket@0.11.4: + resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} + engines: {node: '>=0.8.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@2.0.0: + resolution: {integrity: sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==} + engines: {node: '>=8.0.0'} + + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + + form-data@2.5.4: + resolution: {integrity: sha512-Y/3MmRiR8Nd+0CUtrbvcKtKzLWiUfpQ7DFVggH8PwmGt/0r7RSy32GuP4hpCJlQNEBusisSx1DLtD8uD386HJQ==} + engines: {node: '>= 0.12'} + deprecated: This version has an incorrect dependency; please use v2.5.5 + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fromentries@1.3.2: + resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generative-bayesian-network@2.1.83: + resolution: {integrity: sha512-LssI9es+oUoezoHloFGw0Hts0YEfujjBOE8KNl70oBt4HPjD/4rpUqcgZ/M7RCmgKvkmCyPB2KWowyJHuKyhfw==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@5.0.15: + resolution: {integrity: sha512-c9IPMazfRITpmAAKi22dK1VKxGDX9ehhqfABDriL/lzO92xcUKEJPQHrVA/2YHSNFB4iFlykVmWvwo48nr3OxA==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + glob@9.3.5: + resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} + engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globals@9.18.0: + resolution: {integrity: sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==} + engines: {node: '>=0.10.0'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + got-cjs@12.5.4: + resolution: {integrity: sha512-Uas6lAsP8bRCt5WXGMhjFf/qEHTrm4v4qxGR02rLG2kdG9qedctvlkdwXVcDJ7Cs84X+r4dPU7vdwGjCaspXug==} + engines: {node: '>=12'} + + got-scraping@3.2.15: + resolution: {integrity: sha512-EXqDe4JVyrWZTHhPT1G98+wv+oozDpUHpCLVd6C/HpNtySh6xSSaIgLUCBpAgIT5xheq6ZgNkgb0rA97pq8vZA==} + engines: {node: '>=15.10.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + har-schema@2.0.0: + resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} + engines: {node: '>=4'} + + har-validator@5.1.5: + resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} + engines: {node: '>=6'} + deprecated: this library is no longer supported + + has-ansi@2.0.0: + resolution: {integrity: sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==} + engines: {node: '>=0.10.0'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-color@0.1.7: + resolution: {integrity: sha512-kaNz5OTAYYmt646Hkqw50/qyxP2vFnTVu5AQ1Zmk22Kk5+4Qx6BpO8+u7IKsML5fOsFk0ZT0AcCJNYwcvaLBvw==} + engines: {node: '>=0.10.0'} + + has-flag@1.0.0: + resolution: {integrity: sha512-DyYHfIYwAJmjAjSSPKANxI8bFY9YtFrgkAfinBojQ8YJTOuOuav64tMUJv584SES4xl74PmuaevIyaLESHdTAA==} + engines: {node: '>=0.10.0'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-own@1.0.1: + resolution: {integrity: sha512-RDKhzgQTQfMaLvIFhjahU+2gGnRBK6dYOd5Gd9BzkmnBneOCRYjRC003RIMrdAbH52+l+CnMS4bBCXGer8tEhg==} + deprecated: This project is not maintained. Use Object.hasOwn() instead. + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasha@5.2.2: + resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==} + engines: {node: '>=8'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + header-generator@2.1.82: + resolution: {integrity: sha512-4NjPB0+bAKjPoponSmTOkK58IEF2W22sOJA5O48k/MxbCZgOm+jrU4WVR53Z2I6xFgIPkVrQmKtt1LAbWtfqXw==} + engines: {node: '>=16.0.0'} + + home-or-tmp@2.0.0: + resolution: {integrity: sha512-ycURW7oUxE2sNiPVw1HVEFsW+ecOpJ5zaj7eC0RlwhibhRBod20muUN8qu/gzx956YrLolVvs1MTXwKgC2rVEg==} + engines: {node: '>=0.10.0'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-parser-js@0.5.10: + resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + + http-signature@1.2.0: + resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} + engines: {node: '>=0.8', npm: '>=1.3.7'} + + http2-wrapper@2.2.1: + resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} + engines: {node: '>=10.19.0'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-finite@1.1.0: + resolution: {integrity: sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isparta@4.1.1: + resolution: {integrity: sha512-kGwkNqmALQzdfGhgo5o8kOA88p14R3Lwg0nfQ/qzv4IhB4rXarT9maPMaYbo6cms4poWbeulrlFlURLUR6rDwQ==} + hasBin: true + + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-hook@3.0.0: + resolution: {integrity: sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==} + engines: {node: '>=8'} + + istanbul-lib-instrument@4.0.3: + resolution: {integrity: sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==} + engines: {node: '>=8'} + + istanbul-lib-processinfo@2.0.3: + resolution: {integrity: sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + istanbul@0.4.5: + resolution: {integrity: sha512-nMtdn4hvK0HjUlzr1DrKSUY8ychprt8dzHOgY2KXsIhHu5PuQQEOTM27gV9Xblyon7aUH/TSFIjRHEODF/FRPg==} + deprecated: |- + This module is no longer maintained, try this instead: + npm i nyc + Visit https://istanbul.js.org/integrations for other alternatives. + hasBin: true + + jest-diff@28.1.3: + resolution: {integrity: sha512-8RqP1B/OXzjjTWkqMX67iqgwBVJRgCyKD3L9nq+6ZqJMdvjE8RgHktqZ6jNrkdMT+dJuYNI3rhQpxaz7drJHfw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + + jest-get-type@28.0.2: + resolution: {integrity: sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + + jest-matcher-utils@28.1.3: + resolution: {integrity: sha512-kQeJ7qHemKfbzKoGjHHrRKH6atgxMk8Enkk2iPQ3XwO6oE/KYD8lMYOziCkeSB9G4adPM4nR1DE8Tf5JeWH6Bw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + + jest-message-util@28.1.3: + resolution: {integrity: sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + + jest-util@28.1.3: + resolution: {integrity: sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + + js-tokens@3.0.2: + resolution: {integrity: sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + + jsesc@1.3.0: + resolution: {integrity: sha512-Mke0DA0QjUWuJlhsE0ZPPhYiJkRap642SmI/4ztCFaUs6V2AiH1sfecc+57NgaryfAA2VR3v6O+CSjC1jZJKOA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@0.5.1: + resolution: {integrity: sha512-4xrs1aW+6N5DalkqSVA8fxh458CXvR99WU8WLKmq4v8eWAL86Xo3BVqyd3SkA9wEVjCMqyvvRRkshAdOnBp5rw==} + hasBin: true + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsprim@1.4.2: + resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} + engines: {node: '>=0.6.0'} + + just-extend@6.2.0: + resolution: {integrity: sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + leven@2.1.0: + resolution: {integrity: sha512-nvVPLpIHUxCUoRLrFqTgSxXJ614d8AgQoWl7zPe/2VadE8+1dpU3LBhowRuBAcuwruWtOdD8oYC9jDNJjXDPyA==} + engines: {node: '>=0.10.0'} + + levn@0.3.0: + resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==} + engines: {node: '>= 0.8.0'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.flattendeep@4.4.0: + resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} + + lodash.get@4.4.2: + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@8.0.7: + resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mocha@10.8.2: + resolution: {integrity: sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==} + engines: {node: '>= 14.0.0'} + hasBin: true + + modern-tar@0.7.6: + resolution: {integrity: sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==} + engines: {node: '>=18.0.0'} + + mri@1.1.4: + resolution: {integrity: sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==} + engines: {node: '>=4'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + nise@5.1.9: + resolution: {integrity: sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==} + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-preload@0.2.1: + resolution: {integrity: sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==} + engines: {node: '>=8'} + + node-releases@2.0.44: + resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} + + nomnomnomnom@2.0.1: + resolution: {integrity: sha512-oTu+BNJkTY6Mby5VzHFURovplds+KHknEkHEf+MYeokuoxetzUWi5h6Qg0SSkkoIq449T6EG/qWdbTXD5Cov5Q==} + + nopt@3.0.6: + resolution: {integrity: sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + + nyc@15.1.0: + resolution: {integrity: sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==} + engines: {node: '>=8.9'} + hasBin: true + + oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.8.3: + resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} + engines: {node: '>= 0.8.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + os-homedir@1.0.2: + resolution: {integrity: sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==} + engines: {node: '>=0.10.0'} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + ow@0.28.2: + resolution: {integrity: sha512-dD4UpyBh/9m4X2NVjA+73/ZPBRF+uF4zIMFvvQsabMiEK8x41L3rQ8EENOi35kyyoaJwNxEeJcP6Fj1H4U409Q==} + engines: {node: '>=12'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@3.0.0: + resolution: {integrity: sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-hash@4.0.0: + resolution: {integrity: sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==} + engines: {node: '>=8'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + portastic@1.0.1: + resolution: {integrity: sha512-RIqfFvS85oof66B06xxT37dxdeHt1HnwvEn+HZ84+H+D6ZEMO02Alj8NQcASBfAAuVuy7YMQwa3KVI1a8YJCYg==} + hasBin: true + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + prelude-ls@1.1.2: + resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} + engines: {node: '>= 0.8.0'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@28.1.3: + resolution: {integrity: sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + + private@0.1.8: + resolution: {integrity: sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==} + engines: {node: '>= 0.6'} + + process-on-spawn@1.1.0: + resolution: {integrity: sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==} + engines: {node: '>=8'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy@1.0.2: + resolution: {integrity: sha512-KNac2ueWRpjbUh77OAFPZuNdfEqNynm9DD4xHT14CccGpW8wKZwEkN0yjlb7X9G9Z9F55N0Q+1z+WfgAhwYdzQ==} + hasBin: true + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + puppeteer-core@25.1.0: + resolution: {integrity: sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==} + engines: {node: '>=22.12.0'} + + puppeteer@25.1.0: + resolution: {integrity: sha512-7L6/0JM7XStK99lIL4xQySyNEXNfII6pk0BxkI5kKBTOhR7AsoQiv067YTsE/rIXxQiq9ajlO4WcqBjS/FWK1A==} + engines: {node: '>=22.12.0'} + hasBin: true + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + qs@6.5.5: + resolution: {integrity: sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==} + engines: {node: '>=0.6'} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regenerator-runtime@0.11.1: + resolution: {integrity: sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + release-zalgo@1.0.0: + resolution: {integrity: sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==} + engines: {node: '>=4'} + + repeating@2.0.1: + resolution: {integrity: sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==} + engines: {node: '>=0.10.0'} + + request@2.88.2: + resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} + engines: {node: '>= 6'} + deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve@1.1.7: + resolution: {integrity: sha512-9znBF0vBcaSN3W2j7wKvdERPwqTxSpCq+if5C0WoTCyV9n24rua28jeuQ2pL/HOf+yUe/Mef+H/5p60K0Id3bg==} + + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@4.4.1: + resolution: {integrity: sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==} + engines: {node: '>=14'} + hasBin: true + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + sinon-stub-promise@4.0.0: + resolution: {integrity: sha512-89eBnPV781EXt0q90ystausgLjLwEFQStmh0Cp1xU98DovklkYYMmHk+h6gB+sjnb3BlDJ1RiV4ZKuXgcEZUjA==} + engines: {node: '>= 0.10'} + + sinon@13.0.2: + resolution: {integrity: sha512-KvOrztAVqzSJWMDoxM4vM+GPys1df2VBoXm+YciyB/OLMamfS3VXh3oGh5WtrAGSzrgczNWFFY22oKb7Fi5eeA==} + deprecated: 16.1.1 + + slash@1.0.0: + resolution: {integrity: sha512-3TYDR7xWt4dIqV2JauJr+EJeW356RXijHeUlO+8djJ+uBXPn8/2dpzBc8yQhh583sVvc9CvFAeQVgijsH+PNNg==} + engines: {node: '>=0.10.0'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + socksv5@0.0.6: + resolution: {integrity: sha512-tQpQ0MdNQAsQBDhCXy3OvGGJikh9QOl3PkbwT4POJiQCm/fK4z9AxKQQRG8WLeF6talphnPrSWiZRpTl42rApg==} + engines: {node: '>=0.10.0'} + bundledDependencies: + - ipv6 + + source-map-support@0.4.18: + resolution: {integrity: sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==} + + source-map@0.2.0: + resolution: {integrity: sha512-CBdZ2oa/BHhS4xj5DlhjWNHcan57/5YuvfdLf17iVmIpd9KRm+DFLmC6nBNj+6Ua7Kt3TmOjDpQT1aTYOQtoUA==} + engines: {node: '>=0.8.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + spawn-wrap@2.0.0: + resolution: {integrity: sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==} + engines: {node: '>=8'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-ansi@0.1.1: + resolution: {integrity: sha512-behete+3uqxecWlDAm5lmskaSaISA+ThQ4oNNBDTBJt0x2ppR6IPqfZNuj6BLaLJ/Sji4TPZlcRyOis8wXQTLg==} + engines: {node: '>=0.8.0'} + hasBin: true + + strip-ansi@3.0.1: + resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} + engines: {node: '>=0.10.0'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@2.0.0: + resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==} + engines: {node: '>=0.8.0'} + + supports-color@3.2.3: + resolution: {integrity: sha512-Jds2VIYDrlp5ui7t8abHN2bjAu4LV/q4N2KivFPpGH0lrka0BMq/33AmECUXlKPcHigkNaqfXRENFju+rlcy+A==} + engines: {node: '>=0.8.0'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + to-fast-properties@1.0.3: + resolution: {integrity: sha512-lxrWP8ejsq+7E3nNjwYmUBMAgjMTZoTI+sdBOpvNyijeDLa29LUn9QaoXAHv4+Z578hbmHHJKZknzxVtvo77og==} + engines: {node: '>=0.10.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@2.5.0: + resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} + engines: {node: '>=0.8'} + + trim-right@1.0.1: + resolution: {integrity: sha512-WZGXGstmCWgeevgTL54hrCuw1dyMQIzWy7ZfqRJfSmJZBwklI15egmQytFP6bPidmw3M8d5yEowl1niq4vmqZw==} + engines: {node: '>=0.10.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.2: + resolution: {integrity: sha512-6w9FwtT8WQqRAyTNR+Z+86kghRqpmOLjXUrBlBT6T+CQGDuIMm0VmAqaFUFBIeKDTGobE6/YSigZYLeomzBaRg==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + type-check@0.3.2: + resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==} + engines: {node: '>= 0.8.0'} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + type-fest@0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typed-query-selector@2.12.2: + resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==} + + typedarray-to-buffer@3.1.5: + resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + + typescript-eslint@8.59.3: + resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + + underscore@1.6.0: + resolution: {integrity: sha512-z4o1fvKUojIWh9XuaVLUDdf86RQiq13AC1dmHbTpoyuu+bquHms76v16CjycCbec87J7z0k//SiQVk0sMdFmpQ==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@3.4.0: + resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vali-date@1.0.0: + resolution: {integrity: sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg==} + engines: {node: '>=0.10.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + + webdriver-bidi-protocol@0.4.2: + resolution: {integrity: sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==} + + websocket-driver@0.7.4: + resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + engines: {node: '>=0.8.0'} + + websocket-extensions@0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + workerpool@6.5.1: + resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@3.0.3: + resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-unparser@2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@apify/eslint-config@1.2.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript-eslint@8.59.3(eslint@9.39.4)(typescript@5.9.3))': + dependencies: + '@eslint/compat': 1.4.1(eslint@9.39.4) + eslint: 9.39.4 + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) + eslint-plugin-simple-import-sort: 12.1.1(eslint@9.39.4) + globals: 15.15.0 + optionalDependencies: + typescript-eslint: 8.59.3(eslint@9.39.4)(typescript@5.9.3) + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + '@apify/tsconfig@0.1.2': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@8.1.1) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/compat@1.4.1(eslint@9.39.4)': + dependencies: + '@eslint/core': 0.17.0 + optionalDependencies: + eslint: 9.39.4 + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@8.1.1) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/expect-utils@28.1.3': + dependencies: + jest-get-type: 28.0.2 + + '@jest/schemas@28.1.3': + dependencies: + '@sinclair/typebox': 0.24.51 + + '@jest/types@28.1.3': + dependencies: + '@jest/schemas': 28.1.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.19.41 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@puppeteer/browsers@3.0.4': + dependencies: + modern-tar: 0.7.6 + yargs: 17.7.2 + + '@rtsao/scc@1.1.0': {} + + '@sinclair/typebox@0.24.51': {} + + '@sindresorhus/is@4.6.0': {} + + '@sinonjs/commons@1.8.6': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@11.3.1': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sinonjs/fake-timers@9.1.2': + dependencies: + '@sinonjs/commons': 1.8.6 + + '@sinonjs/samsam@6.1.3': + dependencies: + '@sinonjs/commons': 1.8.6 + lodash.get: 4.4.2 + type-detect: 4.1.0 + + '@sinonjs/text-encoding@0.7.3': {} + + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + + '@types/estree@1.0.9': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@28.1.8': + dependencies: + expect: 28.1.3 + pretty-format: 28.1.3 + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@20.19.41': + dependencies: + undici-types: 6.21.0 + + '@types/responselike@1.0.0': + dependencies: + '@types/node': 20.19.41 + + '@types/stack-utils@2.0.3': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/type-utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.3 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.3 + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.3(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) + '@typescript-eslint/types': 8.59.3 + debug: 4.4.3(supports-color@8.1.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.3': + dependencies: + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/visitor-keys': 8.59.3 + + '@typescript-eslint/tsconfig-utils@8.59.3(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.3(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.3': {} + + '@typescript-eslint/typescript-estree@8.59.3(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.3(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/visitor-keys': 8.59.3 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 10.2.5 + semver: 7.8.0 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.3(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.3': + dependencies: + '@typescript-eslint/types': 8.59.3 + eslint-visitor-keys: 5.0.1 + + abbrev@1.0.9: {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + adm-zip@0.5.17: {} + + agent-base@7.1.4: {} + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + amdefine@1.0.1: + optional: true + + ansi-colors@4.1.3: {} + + ansi-regex@2.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-styles@1.0.0: {} + + ansi-styles@2.2.1: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + append-transform@2.0.0: + dependencies: + default-require-extensions: 3.0.1 + + archy@1.0.0: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + args@5.0.1: + dependencies: + camelcase: 5.0.0 + chalk: 2.4.2 + leven: 2.1.0 + mri: 1.1.4 + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-flatten@1.1.1: {} + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + assert-plus@1.0.0: {} + + assertion-error@1.1.0: {} + + async-function@1.0.0: {} + + async@1.5.2: {} + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + + babel-code-frame@6.26.0: + dependencies: + chalk: 1.1.3 + esutils: 2.0.3 + js-tokens: 3.0.2 + + babel-core@6.26.3: + dependencies: + babel-code-frame: 6.26.0 + babel-generator: 6.26.1 + babel-helpers: 6.24.1 + babel-messages: 6.23.0 + babel-register: 6.26.0 + babel-runtime: 6.26.0 + babel-template: 6.26.0 + babel-traverse: 6.26.0 + babel-types: 6.26.0 + babylon: 6.18.0 + convert-source-map: 1.9.0 + debug: 2.6.9 + json5: 0.5.1 + lodash: 4.18.1 + minimatch: 3.1.5 + path-is-absolute: 1.0.1 + private: 0.1.8 + slash: 1.0.0 + source-map: 0.5.7 + transitivePeerDependencies: + - supports-color + + babel-generator@6.26.1: + dependencies: + babel-messages: 6.23.0 + babel-runtime: 6.26.0 + babel-types: 6.26.0 + detect-indent: 4.0.0 + jsesc: 1.3.0 + lodash: 4.18.1 + source-map: 0.5.7 + trim-right: 1.0.1 + + babel-helpers@6.24.1: + dependencies: + babel-runtime: 6.26.0 + babel-template: 6.26.0 + transitivePeerDependencies: + - supports-color + + babel-messages@6.23.0: + dependencies: + babel-runtime: 6.26.0 + + babel-register@6.26.0: + dependencies: + babel-core: 6.26.3 + babel-runtime: 6.26.0 + core-js: 2.6.12 + home-or-tmp: 2.0.0 + lodash: 4.18.1 + mkdirp: 0.5.6 + source-map-support: 0.4.18 + transitivePeerDependencies: + - supports-color + + babel-runtime@6.26.0: + dependencies: + core-js: 2.6.12 + regenerator-runtime: 0.11.1 + + babel-template@6.26.0: + dependencies: + babel-runtime: 6.26.0 + babel-traverse: 6.26.0 + babel-types: 6.26.0 + babylon: 6.18.0 + lodash: 4.18.1 + transitivePeerDependencies: + - supports-color + + babel-traverse@6.26.0: + dependencies: + babel-code-frame: 6.26.0 + babel-messages: 6.23.0 + babel-runtime: 6.26.0 + babel-types: 6.26.0 + babylon: 6.18.0 + debug: 2.6.9 + globals: 9.18.0 + invariant: 2.2.4 + lodash: 4.18.1 + transitivePeerDependencies: + - supports-color + + babel-types@6.26.0: + dependencies: + babel-runtime: 6.26.0 + esutils: 2.0.3 + lodash: 4.18.1 + to-fast-properties: 1.0.3 + + babylon@6.18.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.30: {} + + basic-auth-parser@0.0.2: {} + + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + + binary-extensions@2.3.0: {} + + bluebird@2.11.0: {} + + body-parser@1.20.5: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-stdout@1.3.1: {} + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.30 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.358 + node-releases: 2.0.44 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bytes@3.1.2: {} + + cacheable-lookup@6.1.0: {} + + cacheable-request@7.0.2: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + + caching-transform@4.0.0: + dependencies: + hasha: 5.2.2 + make-dir: 3.1.0 + package-hash: 4.0.0 + write-file-atomic: 3.0.3 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@5.0.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001793: {} + + caseless@0.12.0: {} + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + chalk@0.4.0: + dependencies: + ansi-styles: 1.0.0 + has-color: 0.1.7 + strip-ansi: 0.1.1 + + chalk@1.1.3: + dependencies: + ansi-styles: 2.2.1 + escape-string-regexp: 1.0.5 + has-ansi: 2.0.0 + strip-ansi: 3.0.1 + supports-color: 2.0.0 + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chromium-bidi@16.0.1(devtools-protocol@0.0.1624250): + dependencies: + devtools-protocol: 0.0.1624250 + mitt: 3.0.1 + zod: 3.25.76 + + ci-info@3.9.0: {} + + clean-stack@2.2.0: {} + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@2.20.3: {} + + commondir@1.0.1: {} + + concat-map@0.0.1: {} + + confusing-browser-globals@1.0.11: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@1.9.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + core-js@2.6.12: {} + + core-util-is@1.0.2: {} + + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decamelize@1.2.0: {} + + decamelize@4.0.0: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + + deep-is@0.1.4: {} + + default-require-extensions@3.0.1: + dependencies: + strip-bom: 4.0.0 + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-indent@4.0.0: + dependencies: + repeating: 2.0.1 + + devtools-protocol@0.0.1624250: {} + + diff-sequences@28.1.1: {} + + diff@5.2.2: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.358: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.3 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es6-error@4.1.1: {} + + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escodegen@1.14.3: + dependencies: + esprima: 4.0.1 + estraverse: 4.3.0 + esutils: 2.0.3 + optionator: 0.8.3 + optionalDependencies: + source-map: 0.6.1 + + escodegen@1.8.1: + dependencies: + esprima: 2.7.3 + estraverse: 1.9.3 + esutils: 2.0.3 + optionator: 0.8.3 + optionalDependencies: + source-map: 0.2.0 + + eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4): + dependencies: + confusing-browser-globals: 1.0.11 + eslint: 9.39.4 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) + object.assign: 4.1.7 + object.entries: 1.1.9 + semver: 6.3.1 + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.2 + resolve: 2.0.0-next.7 + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + hasown: 2.0.3 + is-core-module: 2.16.2 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@8.1.1) + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esprima@2.7.3: {} + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@1.9.3: {} + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + expect@28.1.3: + dependencies: + '@jest/expect-utils': 28.1.3 + jest-get-type: 28.0.2 + jest-matcher-utils: 28.1.3 + jest-message-util: 28.1.3 + jest-util: 28.1.3 + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.5 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend@3.0.2: {} + + extsprintf@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + faye-websocket@0.11.4: + dependencies: + websocket-driver: 0.7.4 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flat@5.0.2: {} + + flatted@3.4.2: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@2.0.0: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 3.0.7 + + forever-agent@0.6.1: {} + + form-data-encoder@1.7.2: {} + + form-data@2.5.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + has-own: 1.0.1 + mime-types: 2.1.35 + safe-buffer: 5.2.1 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fromentries@1.3.2: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.3 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generative-bayesian-network@2.1.83: + dependencies: + adm-zip: 0.5.17 + tslib: 2.8.1 + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-func-name@2.0.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + get-stream@6.0.1: {} + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@5.0.15: + dependencies: + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.9 + once: 1.4.0 + + glob@9.3.5: + dependencies: + fs.realpath: 1.0.0 + minimatch: 8.0.7 + minipass: 4.2.8 + path-scurry: 1.11.1 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globals@9.18.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + got-cjs@12.5.4: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/responselike': 1.0.0 + cacheable-lookup: 6.1.0 + cacheable-request: 7.0.2 + decompress-response: 6.0.0 + form-data-encoder: 1.7.2 + get-stream: 6.0.1 + http2-wrapper: 2.2.1 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + + got-scraping@3.2.15: + dependencies: + got-cjs: 12.5.4 + header-generator: 2.1.82 + http2-wrapper: 2.2.1 + mimic-response: 3.1.0 + ow: 0.28.2 + quick-lru: 5.1.1 + tslib: 2.8.1 + + graceful-fs@4.2.11: {} + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + har-schema@2.0.0: {} + + har-validator@5.1.5: + dependencies: + ajv: 6.15.0 + har-schema: 2.0.0 + + has-ansi@2.0.0: + dependencies: + ansi-regex: 2.1.1 + + has-bigints@1.1.0: {} + + has-color@0.1.7: {} + + has-flag@1.0.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-own@1.0.1: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasha@5.2.2: + dependencies: + is-stream: 2.0.1 + type-fest: 0.8.1 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + header-generator@2.1.82: + dependencies: + browserslist: 4.28.2 + generative-bayesian-network: 2.1.83 + ow: 0.28.2 + tslib: 2.8.1 + + home-or-tmp@2.0.0: + dependencies: + os-homedir: 1.0.2 + os-tmpdir: 1.0.2 + + html-escaper@2.0.2: {} + + http-cache-semantics@4.2.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-parser-js@0.5.10: {} + + http-signature@1.2.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 1.4.2 + sshpk: 1.18.0 + + http2-wrapper@2.2.1: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.3 + side-channel: 1.1.0 + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-finite@1.1.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-plain-obj@2.1.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-typedarray@1.0.0: {} + + is-unicode-supported@0.1.0: {} + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-windows@1.0.2: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isparta@4.1.1: + dependencies: + babel-core: 6.26.3 + escodegen: 1.14.3 + esprima: 4.0.1 + istanbul: 0.4.5 + mkdirp: 0.5.6 + nomnomnomnom: 2.0.1 + object-assign: 4.1.1 + source-map: 0.5.7 + which: 1.3.1 + transitivePeerDependencies: + - supports-color + + isstream@0.1.2: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-hook@3.0.0: + dependencies: + append-transform: 2.0.0 + + istanbul-lib-instrument@4.0.3: + dependencies: + '@babel/core': 7.29.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-processinfo@2.0.3: + dependencies: + archy: 1.0.0 + cross-spawn: 7.0.6 + istanbul-lib-coverage: 3.2.2 + p-map: 3.0.0 + rimraf: 3.0.2 + uuid: 8.3.2 + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + istanbul@0.4.5: + dependencies: + abbrev: 1.0.9 + async: 1.5.2 + escodegen: 1.8.1 + esprima: 2.7.3 + glob: 5.0.15 + handlebars: 4.7.9 + js-yaml: 3.14.2 + mkdirp: 0.5.6 + nopt: 3.0.6 + once: 1.4.0 + resolve: 1.1.7 + supports-color: 3.2.3 + which: 1.3.1 + wordwrap: 1.0.0 + + jest-diff@28.1.3: + dependencies: + chalk: 4.1.2 + diff-sequences: 28.1.1 + jest-get-type: 28.0.2 + pretty-format: 28.1.3 + + jest-get-type@28.0.2: {} + + jest-matcher-utils@28.1.3: + dependencies: + chalk: 4.1.2 + jest-diff: 28.1.3 + jest-get-type: 28.0.2 + pretty-format: 28.1.3 + + jest-message-util@28.1.3: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 28.1.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 28.1.3 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-util@28.1.3: + dependencies: + '@jest/types': 28.1.3 + '@types/node': 20.19.41 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + js-tokens@3.0.2: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsbn@0.1.1: {} + + jsesc@1.3.0: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema@0.4.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json-stringify-safe@5.0.1: {} + + json5@0.5.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsprim@1.4.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + + just-extend@6.2.0: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + leven@2.1.0: {} + + levn@0.3.0: + dependencies: + prelude-ls: 1.1.2 + type-check: 0.3.2 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@3.1.3: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.flattendeep@4.4.0: {} + + lodash.get@4.4.2: {} + + lodash.isequal@4.5.0: {} + + lodash.merge@4.6.2: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + lowercase-keys@2.0.0: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.0 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + + minimatch@8.0.7: + dependencies: + brace-expansion: 2.1.0 + + minimist@1.2.8: {} + + minipass@4.2.8: {} + + minipass@7.1.3: {} + + mitt@3.0.1: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mocha@10.8.2: + dependencies: + ansi-colors: 4.1.3 + browser-stdout: 1.3.1 + chokidar: 3.6.0 + debug: 4.4.3(supports-color@8.1.1) + diff: 5.2.2 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 8.1.0 + he: 1.2.0 + js-yaml: 4.1.1 + log-symbols: 4.1.0 + minimatch: 5.1.9 + ms: 2.1.3 + serialize-javascript: 6.0.2 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 6.5.1 + yargs: 16.2.0 + yargs-parser: 20.2.9 + yargs-unparser: 2.0.0 + + modern-tar@0.7.6: {} + + mri@1.1.4: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + neo-async@2.6.2: {} + + nise@5.1.9: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 11.3.1 + '@sinonjs/text-encoding': 0.7.3 + just-extend: 6.2.0 + path-to-regexp: 6.3.0 + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-preload@0.2.1: + dependencies: + process-on-spawn: 1.1.0 + + node-releases@2.0.44: {} + + nomnomnomnom@2.0.1: + dependencies: + chalk: 0.4.0 + underscore: 1.6.0 + + nopt@3.0.6: + dependencies: + abbrev: 1.0.9 + + normalize-path@3.0.0: {} + + normalize-url@6.1.0: {} + + nyc@15.1.0: + dependencies: + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + caching-transform: 4.0.0 + convert-source-map: 1.9.0 + decamelize: 1.2.0 + find-cache-dir: 3.3.2 + find-up: 4.1.0 + foreground-child: 2.0.0 + get-package-type: 0.1.0 + glob: 7.2.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-hook: 3.0.0 + istanbul-lib-instrument: 4.0.3 + istanbul-lib-processinfo: 2.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + make-dir: 3.1.0 + node-preload: 0.2.1 + p-map: 3.0.0 + process-on-spawn: 1.1.0 + resolve-from: 5.0.0 + rimraf: 3.0.2 + signal-exit: 3.0.7 + spawn-wrap: 2.0.0 + test-exclude: 6.0.0 + yargs: 15.4.1 + transitivePeerDependencies: + - supports-color + + oauth-sign@0.9.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.8.3: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.3.0 + prelude-ls: 1.1.2 + type-check: 0.3.2 + word-wrap: 1.2.5 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + os-homedir@1.0.2: {} + + os-tmpdir@1.0.2: {} + + ow@0.28.2: + dependencies: + '@sindresorhus/is': 4.6.0 + callsites: 3.1.0 + dot-prop: 6.0.1 + lodash.isequal: 4.5.0 + vali-date: 1.0.0 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-cancelable@2.1.1: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@3.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-try@2.2.0: {} + + package-hash@4.0.0: + dependencies: + graceful-fs: 4.2.11 + hasha: 5.2.2 + lodash.flattendeep: 4.4.0 + release-zalgo: 1.0.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-to-regexp@0.1.13: {} + + path-to-regexp@6.3.0: {} + + pathval@1.1.1: {} + + performance-now@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + portastic@1.0.1: + dependencies: + bluebird: 2.11.0 + commander: 2.20.3 + debug: 2.6.9 + transitivePeerDependencies: + - supports-color + + possible-typed-array-names@1.1.0: {} + + prelude-ls@1.1.2: {} + + prelude-ls@1.2.1: {} + + pretty-format@28.1.3: + dependencies: + '@jest/schemas': 28.1.3 + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + private@0.1.8: {} + + process-on-spawn@1.1.0: + dependencies: + fromentries: 1.3.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy@1.0.2: + dependencies: + args: 5.0.1 + basic-auth-parser: 0.0.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + puppeteer-core@25.1.0: + dependencies: + '@puppeteer/browsers': 3.0.4 + chromium-bidi: 16.0.1(devtools-protocol@0.0.1624250) + devtools-protocol: 0.0.1624250 + typed-query-selector: 2.12.2 + webdriver-bidi-protocol: 0.4.2 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - proxy-agent + - utf-8-validate + + puppeteer@25.1.0: + dependencies: + '@puppeteer/browsers': 3.0.4 + chromium-bidi: 16.0.1(devtools-protocol@0.0.1624250) + devtools-protocol: 0.0.1624250 + lilconfig: 3.1.3 + puppeteer-core: 25.1.0 + typed-query-selector: 2.12.2 + transitivePeerDependencies: + - bufferutil + - proxy-agent + - utf-8-validate + + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + + qs@6.5.5: {} + + quick-lru@5.1.1: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-is@18.3.1: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regenerator-runtime@0.11.1: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + release-zalgo@1.0.0: + dependencies: + es6-error: 4.1.1 + + repeating@2.0.1: + dependencies: + is-finite: 1.1.0 + + request@2.88.2: + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.5.4 + har-validator: 5.1.5 + http-signature: 1.2.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + oauth-sign: 0.9.0 + performance-now: 2.1.0 + qs: 6.5.5 + safe-buffer: 5.2.1 + tough-cookie: 2.5.0 + tunnel-agent: 0.6.0 + uuid: 3.4.0 + + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + + resolve-alpn@1.2.1: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve@1.1.7: {} + + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rimraf@4.4.1: + dependencies: + glob: 9.3.5 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + semver@6.3.1: {} + + semver@7.8.0: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + sinon-stub-promise@4.0.0: {} + + sinon@13.0.2: + dependencies: + '@sinonjs/commons': 1.8.6 + '@sinonjs/fake-timers': 9.1.2 + '@sinonjs/samsam': 6.1.3 + diff: 5.2.2 + nise: 5.1.9 + supports-color: 7.2.0 + + slash@1.0.0: {} + + slash@3.0.0: {} + + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + + socksv5@0.0.6: {} + + source-map-support@0.4.18: + dependencies: + source-map: 0.5.7 + + source-map@0.2.0: + dependencies: + amdefine: 1.0.1 + optional: true + + source-map@0.5.7: {} + + source-map@0.6.1: {} + + spawn-wrap@2.0.0: + dependencies: + foreground-child: 2.0.0 + is-windows: 1.0.2 + make-dir: 3.1.0 + rimraf: 3.0.2 + signal-exit: 3.0.7 + which: 2.0.2 + + sprintf-js@1.0.3: {} + + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + statuses@2.0.2: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-ansi@0.1.1: {} + + strip-ansi@3.0.1: + dependencies: + ansi-regex: 2.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-json-comments@3.1.1: {} + + supports-color@2.0.0: {} + + supports-color@3.2.3: + dependencies: + has-flag: 1.0.0 + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + through@2.3.8: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-fast-properties@1.0.3: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tough-cookie@2.5.0: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + + trim-right@1.0.1: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.22.2: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + tweetnacl@0.14.5: {} + + type-check@0.3.2: + dependencies: + prelude-ls: 1.1.2 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-detect@4.1.0: {} + + type-fest@0.8.1: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typed-query-selector@2.12.2: {} + + typedarray-to-buffer@3.1.5: + dependencies: + is-typedarray: 1.0.0 + + typescript-eslint@8.59.3(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + uglify-js@3.19.3: + optional: true + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + underscore@1.13.8: {} + + underscore@1.6.0: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + utils-merge@1.0.1: {} + + uuid@3.4.0: {} + + uuid@8.3.2: {} + + vali-date@1.0.0: {} + + vary@1.1.2: {} + + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + + webdriver-bidi-protocol@0.4.2: {} + + websocket-driver@0.7.4: + dependencies: + http-parser-js: 0.5.10 + safe-buffer: 5.2.1 + websocket-extensions: 0.1.4 + + websocket-extensions@0.1.4: {} + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-module@2.0.1: {} + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + workerpool@6.5.1: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + write-file-atomic@3.0.3: + dependencies: + imurmurhash: 0.1.4 + is-typedarray: 1.0.0 + signal-exit: 3.0.7 + typedarray-to-buffer: 3.1.5 + + ws@8.20.1: {} + + ws@8.21.0: {} + + y18n@4.0.3: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs-parser@20.2.9: {} + + yargs-parser@21.1.1: {} + + yargs-unparser@2.0.0: + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..500cb6cb --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,28 @@ +# 24-hour quarantine on newly published versions to blunt supply-chain attacks +# (compromised packages typically get yanked within hours of discovery). +# Excludes are scopes we publish ourselves and trust on the same release cadence. +minimumReleaseAge: 1440 +minimumReleaseAgeExclude: + - "@apify/*" + +# Postinstall protection — pnpm only runs build scripts for packages with `: true` here. +# `: false` is an explicit-deny marker. New transitives with build scripts surface as +# placeholders the first time they appear — review them, then change to `true` or `false`. +allowBuilds: + # puppeteer downloads its Chromium binary in postinstall — required for the e2e + # tests that drive headless Chrome through the proxy. + puppeteer: true + # esbuild's postinstall installs the platform-specific binary — required by tsx. + esbuild: true + # core-js' postinstall only prints a donation banner; no functional impact. + core-js: false + +# When `true`, `pnpm install --frozen-lockfile` fails (ERR_PNPM_IGNORED_BUILDS) +# whenever a new build-script transitive lands that isn't in `allowBuilds`. We +# pin to `false` so the default warning is enough — we still get the security +# benefit (only allowlisted scripts run) without making installs flaky on every +# dependency bump. +strictDepBuilds: false + +overrides: + "form-data": "2.5.4" diff --git a/scripts/test-docker-all.sh b/scripts/test-docker-all.sh new file mode 100755 index 00000000..2059d5cb --- /dev/null +++ b/scripts/test-docker-all.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +echo "Starting parallel Docker tests for Node 20, 22, and 24..." + +# Run builds in parallel, capture PIDs. +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 $pid20 +ec20=$? +wait $pid22 +ec22=$? +wait $pid24 +ec24=$? + +echo "" +echo "========== Results ==========" +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 $((ec20 + ec22 + ec24)) diff --git a/src/anonymize_proxy.js b/src/anonymize_proxy.js deleted file mode 100644 index e28555d9..00000000 --- a/src/anonymize_proxy.js +++ /dev/null @@ -1,109 +0,0 @@ -import { Server } from './server'; -import { - parseUrl, nodeify, -} from './tools'; - -// Dictionary, key is value returned from anonymizeProxy(), value is Server instance. -const anonymizedProxyUrlToServer = {}; - -/** - * Parses and validates a HTTP proxy URL. If the proxy requires authentication, then the function - * starts an open local proxy server that forwards to the upstream proxy. - * @param proxyUrl - * @param callback Optional callback that receives the anonymous proxy URL - * @return If no callback was supplied, returns a promise that resolves to a String with - * anonymous proxy URL or the original URL if it was already anonymous. - */ -export const anonymizeProxy = (proxyUrl, callback) => { - const parsedProxyUrl = parseUrl(proxyUrl); - if (!parsedProxyUrl.host || !parsedProxyUrl.port) { - throw new Error('Invalid "proxyUrl" option: the URL must contain both hostname and port.'); - } - if (parsedProxyUrl.protocol !== 'http:') { - throw new Error('Invalid "proxyUrl" option: only HTTP proxies are currently supported.'); - } - - // If upstream proxy requires no password, return it directly - if (!parsedProxyUrl.username && !parsedProxyUrl.password) { - return nodeify(Promise.resolve(proxyUrl), callback); - } - - let server; - - const startServer = () => { - return Promise.resolve() - .then(() => { - server = new Server({ - // verbose: true, - port: 0, - prepareRequestFunction: () => { - return { - requestAuthentication: false, - upstreamProxyUrl: proxyUrl, - }; - }, - }); - - return server.listen(); - }); - }; - - const promise = startServer() - .then(() => { - const url = `http://127.0.0.1:${server.port}`; - anonymizedProxyUrlToServer[url] = server; - return url; - }); - - return nodeify(promise, callback); -}; - -/** - * Closes anonymous proxy previously started by `anonymizeProxy()`. - * If proxy was not found or was already closed, the function has no effect - * and its result if `false`. Otherwise the result is `true`. - * @param anonymizedProxyUrl - * @param closeConnections If true, pending proxy connections are forcibly closed. - * If `false`, the function will wait until all connections are closed, which can take a long time. - * @param callback Optional callback - * @returns Returns a promise if no callback was supplied - */ -export const closeAnonymizedProxy = (anonymizedProxyUrl, closeConnections, callback) => { - if (typeof anonymizedProxyUrl !== 'string') { - throw new Error('The "anonymizedProxyUrl" parameter must be a string'); - } - - const server = anonymizedProxyUrlToServer[anonymizedProxyUrl]; - if (!server) { - return nodeify(Promise.resolve(false), callback); - } - - delete anonymizedProxyUrlToServer[anonymizedProxyUrl]; - - const promise = server.close(closeConnections) - .then(() => { - return true; - }); - return nodeify(promise, callback); -}; - -/** - * Add a callback on 'tunnelConnectResponded' Event in order to get headers from CONNECT tunnel to proxy - * Useful for some proxies that are using headers to send information like ProxyMesh - * @param anonymizedProxyUrl - * @param tunnelConnectRespondedCallback Callback to be invoked upon receiving the response. It - * shall take an object as its parameter, with three keys `response`, `socket`, and `head` as - * described here: https://nodejs.org/api/http.html#http_event_connect - * @returns `true` if the callback is successfully configured, otherwise `false` (e.g. when an - * invalid proxy URL is given). - */ -export const listenConnectAnonymizedProxy = (anonymizedProxyUrl, tunnelConnectRespondedCallback) => { - const server = anonymizedProxyUrlToServer[anonymizedProxyUrl]; - if (!server) { - return false; - } - server.on('tunnelConnectResponded', ({ response, socket, head }) => { - tunnelConnectRespondedCallback({ response, socket, head }); - }); - return true; -}; diff --git a/src/anonymize_proxy.ts b/src/anonymize_proxy.ts new file mode 100644 index 00000000..736efb76 --- /dev/null +++ b/src/anonymize_proxy.ts @@ -0,0 +1,129 @@ +import type { Buffer } from 'node:buffer'; +import type http from 'node:http'; +import type net from 'node:net'; +import { URL } from 'node:url'; + +import { Server, SOCKS_PROTOCOLS } from './server.js'; + +// Dictionary, key is value returned from anonymizeProxy(), value is Server instance. +const anonymizedProxyUrlToServer: Record = {}; + +export interface AnonymizeProxyOptions { + url: string; + port: number; + ignoreProxyCertificate?: boolean; +} + +/** + * Parses and validates a HTTP proxy URL. If the proxy requires authentication, + * or if it is an HTTPS proxy and `ignoreProxyCertificate` is `true`, then the function + * starts an open local proxy server that forwards to the upstream proxy. + */ +export const anonymizeProxy = async ( + options: string | AnonymizeProxyOptions, +): Promise => { + let proxyUrl: string; + let port = 0; + let ignoreProxyCertificate = false; + + if (typeof options === 'string') { + proxyUrl = options; + } else { + proxyUrl = options.url; + port = options.port; + + if (port < 0 || port > 65535) { + throw new Error( + 'Invalid "port" option: only values equals or between 0-65535 are valid', + ); + } + + if (options.ignoreProxyCertificate !== undefined) { + ignoreProxyCertificate = options.ignoreProxyCertificate; + } + } + + const parsedProxyUrl = new URL(proxyUrl); + if (!['http:', 'https:', ...SOCKS_PROTOCOLS].includes(parsedProxyUrl.protocol)) { + throw new Error(`Invalid "proxyUrl" provided: URL must have one of the following protocols: "http", "https", ${SOCKS_PROTOCOLS.map((p) => `"${p.replace(':', '')}"`).join(', ')} (was "${parsedProxyUrl}")`); + } + + // 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 proxyUrl; + } + + const server = new Server({ + // verbose: true, + port, + host: '127.0.0.1', + prepareRequestFunction: () => { + return { + requestAuthentication: false, + upstreamProxyUrl: proxyUrl, + ignoreUpstreamProxyCertificate: ignoreProxyCertificate, + }; + }, + }) as Server & { port: number }; + + await server.listen(); + + const url = `http://127.0.0.1:${server.port}`; + anonymizedProxyUrlToServer[url] = server; + return url; +}; + +/** + * Closes anonymous proxy previously started by `anonymizeProxy()`. + * If proxy was not found or was already closed, the function has no effect + * and its result if `false`. Otherwise the result is `true`. + * @param closeConnections If true, pending proxy connections are forcibly closed. + */ +export const closeAnonymizedProxy = async ( + anonymizedProxyUrl: string, + closeConnections: boolean, +): Promise => { + if (typeof anonymizedProxyUrl !== 'string') { + throw new Error('The "anonymizedProxyUrl" parameter must be a string'); + } + + const server = anonymizedProxyUrlToServer[anonymizedProxyUrl]; + if (!server) { + return false; + } + + delete anonymizedProxyUrlToServer[anonymizedProxyUrl]; + + await server.close(closeConnections); + return true; +}; + +type Callback = ({ + response, + socket, + head, +}: { + response: http.IncomingMessage; + socket: net.Socket; + head: Buffer; +}) => void; + +/** + * Add a callback on 'tunnelConnectResponded' Event in order to get headers from CONNECT tunnel to proxy + * Useful for some proxies that are using headers to send information like ProxyMesh + * @returns `true` if the callback is successfully configured, otherwise `false` (e.g. when an + * invalid proxy URL is given). + */ +export const listenConnectAnonymizedProxy = ( + anonymizedProxyUrl: string, + tunnelConnectRespondedCallback: Callback, +): boolean => { + const server = anonymizedProxyUrlToServer[anonymizedProxyUrl]; + if (!server) { + return false; + } + server.on('tunnelConnectResponded', ({ response, socket, head }) => { + tunnelConnectRespondedCallback({ response, socket, head }); + }); + return true; +}; diff --git a/src/chain.ts b/src/chain.ts new file mode 100644 index 00000000..5e02bfd0 --- /dev/null +++ b/src/chain.ts @@ -0,0 +1,217 @@ +import type { Buffer } from 'node:buffer'; +import type dns from 'node:dns'; +import type { EventEmitter } from 'node:events'; +import http from 'node:http'; +import https from 'node:https'; +import type { URL } from 'node:url'; + +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; + headers: Record; + path?: string; + localAddress?: string; + family?: number; + lookup?: typeof dns['lookup']; +} + +export interface HandlerOpts { + upstreamProxyUrlParsed: URL; + ignoreUpstreamProxyCertificate: boolean; + localAddress?: string; + ipFamily?: number; + dnsLookup?: typeof dns['lookup']; + customTag?: unknown; + httpAgent?: http.Agent; + httpsAgent?: https.Agent; +} + +interface ChainOpts { + request: { url?: string }; + sourceSocket: Socket; + head?: Buffer; + handlerOpts: HandlerOpts; + server: EventEmitter & { log: (connectionId: unknown, str: string) => void }; + isPlain: boolean; +} + +/** + * Passes the traffic to upstream HTTP proxy server. + * Client -> Apify -> Upstream -> Web + * Client <- Apify <- Upstream <- Web + */ +export const chain = ( + { + request, + sourceSocket, + head, + handlerOpts, + server, + isPlain, + }: ChainOpts, +): void => { + if (head && head.length > 0) { + // HTTP/1.1 has no defined semantics when sending payload along with CONNECT and servers can reject the request. + // HTTP/2 only says that subsequent DATA frames must be transferred after HEADERS has been sent. + // HTTP/3 says that all DATA frames should be transferred (implies pre-HEADERS data). + // + // Let's go with the HTTP/3 behavior. + // There are also clients that send payload along with CONNECT to save milliseconds apparently. + // Beware of upstream proxy servers that send out valid CONNECT responses with diagnostic data such as IPs! + sourceSocket.unshift(head); + } + + const { proxyChainId } = sourceSocket; + + const { upstreamProxyUrlParsed: proxy, customTag } = handlerOpts; + + const options: Options = { + method: 'CONNECT', + path: request.url, + headers: { + host: request.url!, + }, + localAddress: handlerOpts.localAddress, + family: handlerOpts.ipFamily, + lookup: handlerOpts.dnsLookup, + }; + + if (proxy.username || proxy.password) { + options.headers['proxy-authorization'] = getBasicAuthorizationHeader(proxy); + } + + const client = proxy.protocol === 'https:' + ? https.request(proxy.origin, { + ...options, + rejectUnauthorized: !handlerOpts.ignoreUpstreamProxyCertificate, + agent: handlerOpts.httpsAgent, + }) + : http.request(proxy.origin, { + ...options, + agent: handlerOpts.httpAgent, + }); + + client.once('socket', (targetSocket: SocketWithPreviousStats) => { + // Socket can be re-used by multiple requests. + // That's why we need to track the previous stats. + targetSocket.previousBytesRead = targetSocket.bytesRead; + targetSocket.previousBytesWritten = targetSocket.bytesWritten; + countTargetBytes(sourceSocket, targetSocket); + }); + + client.on('connect', (response, targetSocket, clientHead) => { + if (sourceSocket.readyState !== 'open') { + // Sanity check, should never reach. + targetSocket.destroy(); + return; + } + + targetSocket.on('error', (error) => { + server.log(proxyChainId, `Chain Destination Socket Error: ${error.stack}`); + + sourceSocket.destroy(); + }); + + sourceSocket.on('error', (error) => { + server.log(proxyChainId, `Chain Source Socket Error: ${error.stack}`); + + targetSocket.destroy(); + }); + + if (response.statusCode !== 200) { + server.log(proxyChainId, `Failed to authenticate upstream proxy: ${response.statusCode}`); + + if (isPlain) { + sourceSocket.end(); + } else { + const { statusCode } = response; + const status = statusCode === 401 || statusCode === 407 + ? badGatewayStatusCodes.AUTH_FAILED + : badGatewayStatusCodes.NON_200; + + sourceSocket.end(createCustomStatusHttpResponse(status, `UPSTREAM${statusCode}`)); + } + + targetSocket.end(); + + server.emit('tunnelConnectFailed', { + proxyChainId, + response, + customTag, + socket: targetSocket, + head: clientHead, + }); + + return; + } + + if (clientHead.length > 0) { + // See comment above + targetSocket.unshift(clientHead); + } + + server.emit('tunnelConnectResponded', { + proxyChainId, + response, + customTag, + socket: targetSocket, + head: clientHead, + }); + + sourceSocket.write(isPlain ? '' : `HTTP/1.1 200 Connection Established\r\n\r\n`); + + sourceSocket.pipe(targetSocket); + targetSocket.pipe(sourceSocket); + + // Once target socket closes forcibly, the source socket gets paused. + // We need to enable flowing, otherwise the socket would remain open indefinitely. + // Nothing would consume the data, we just want to close the socket. + targetSocket.on('close', () => { + sourceSocket.resume(); + + if (sourceSocket.writable) { + sourceSocket.end(); + } + }); + + // Same here. + sourceSocket.on('close', () => { + targetSocket.resume(); + + if (targetSocket.writable) { + targetSocket.end(); + } + }); + }); + + client.on('error', (error: NodeJS.ErrnoException) => { + server.log(proxyChainId, `Failed to connect to upstream proxy: ${error.stack}`); + + // The end socket may get connected after the client to proxy one gets disconnected. + if (sourceSocket.readyState === 'open') { + if (isPlain) { + sourceSocket.end(); + } else { + const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR; + const response = createCustomStatusHttpResponse(statusCode, error.code ?? 'Upstream Closed Early'); + sourceSocket.end(response); + } + } + }); + + sourceSocket.on('error', () => { + client.destroy(); + }); + + // In case the client ends the socket too early + sourceSocket.on('close', () => { + client.destroy(); + }); + + client.end(); +}; diff --git a/src/chain_socks.ts b/src/chain_socks.ts new file mode 100644 index 00000000..2b874835 --- /dev/null +++ b/src/chain_socks.ts @@ -0,0 +1,136 @@ +import type { Buffer } from 'node:buffer'; +import type { EventEmitter } from 'node:events'; +import type http from 'node:http'; +import type net from 'node:net'; +import { URL } from 'node:url'; + +import { SocksClient, type SocksClientError, type SocksProxy } from 'socks'; + +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; + customTag?: unknown; +} + +interface ChainSocksOpts { + request: http.IncomingMessage, + sourceSocket: Socket; + head: Buffer; + server: EventEmitter & { log: (connectionId: unknown, str: string) => void }; + handlerOpts: HandlerOpts; +} + +const socksProtocolToVersionNumber = (protocol: string): 4 | 5 => { + switch (protocol) { + case 'socks4:': + case 'socks4a:': + return 4; + default: + return 5; + } +}; + +/** + * Tunnels CONNECT requests through a SOCKS upstream proxy. + * + * **Note:** Custom HTTP/HTTPS agents (`httpAgent`, `httpsAgent`) from `prepareRequestFunction` + * are not supported with SOCKS upstream proxies. SOCKS establishes direct TCP socket connections + * using the SocksClient, which bypasses HTTP agent connection pooling. + * + * Client -> Apify (CONNECT) -> Upstream (SOCKS) -> Web + * Client <- Apify (CONNECT) <- Upstream (SOCKS) <- Web + */ +export const chainSocks = async ({ + request, + sourceSocket, + head, + server, + handlerOpts, +}: ChainSocksOpts): Promise => { + const { proxyChainId } = sourceSocket; + + const { hostname, port, username, password } = handlerOpts.upstreamProxyUrlParsed; + + const proxy: SocksProxy = { + host: hostname, + port: Number(port), + type: socksProtocolToVersionNumber(handlerOpts.upstreamProxyUrlParsed.protocol), + userId: decodeURIComponent(username), + password: decodeURIComponent(password), + }; + + if (head && head.length > 0) { + // HTTP/1.1 has no defined semantics when sending payload along with CONNECT and servers can reject the request. + // HTTP/2 only says that subsequent DATA frames must be transferred after HEADERS has been sent. + // HTTP/3 says that all DATA frames should be transferred (implies pre-HEADERS data). + // + // Let's go with the HTTP/3 behavior. + // There are also clients that send payload along with CONNECT to save milliseconds apparently. + // Beware of upstream proxy servers that send out valid CONNECT responses with diagnostic data such as IPs! + sourceSocket.unshift(head); + } + + const url = new URL(`connect://${request.url}`); + const destination = { + port: Number(url.port), + host: url.hostname, + }; + + let targetSocket: net.Socket; + + try { + const client = await SocksClient.createConnection({ + proxy, + command: 'connect', + destination, + }); + targetSocket = client.socket; + + sourceSocket.write(`HTTP/1.1 200 Connection Established\r\n\r\n`); + } catch (error) { + const socksError = error as SocksClientError; + server.log(proxyChainId, `Failed to connect to upstream SOCKS proxy ${socksError.stack}`); + sourceSocket.end(createCustomStatusHttpResponse(socksErrorMessageToStatusCode(socksError.message), socksError.message)); + return; + } + + countTargetBytes(sourceSocket, targetSocket); + + sourceSocket.pipe(targetSocket); + targetSocket.pipe(sourceSocket); + + // Once target socket closes forcibly, the source socket gets paused. + // We need to enable flowing, otherwise the socket would remain open indefinitely. + // Nothing would consume the data, we just want to close the socket. + targetSocket.on('close', () => { + sourceSocket.resume(); + + if (sourceSocket.writable) { + sourceSocket.end(); + } + }); + + // Same here. + sourceSocket.on('close', () => { + targetSocket.resume(); + + if (targetSocket.writable) { + targetSocket.end(); + } + }); + + targetSocket.on('error', (error) => { + server.log(proxyChainId, `Chain SOCKS Destination Socket Error: ${error.stack}`); + + sourceSocket.destroy(); + }); + + sourceSocket.on('error', (error) => { + server.log(proxyChainId, `Chain SOCKS Source Socket Error: ${error.stack}`); + + targetSocket.destroy(); + }); +}; diff --git a/src/custom_connect.ts b/src/custom_connect.ts new file mode 100644 index 00000000..7b609d2a --- /dev/null +++ b/src/custom_connect.ts @@ -0,0 +1,26 @@ +import type http from 'node:http'; +import type net from 'node:net'; +import { promisify } from 'node:util'; + +export const customConnect = async (socket: net.Socket, server: http.Server): Promise => { + // `countTargetBytes(socket, socket)` is incorrect here since `socket` is not a target. + // We would have to create a new stream and pipe traffic through that, + // however this would also increase CPU usage. + // Also, counting bytes here is not correct since we don't know how the response is generated + // (whether any additional sockets are used). + + const asyncWrite = promisify(socket.write).bind(socket); + await asyncWrite('HTTP/1.1 200 Connection Established\r\n\r\n'); + server.emit('connection', socket); + + return new Promise((resolve) => { + if (socket.destroyed) { + resolve(); + return; + } + + socket.once('close', () => { + resolve(); + }); + }); +}; diff --git a/src/custom_response.ts b/src/custom_response.ts new file mode 100644 index 00000000..8058f877 --- /dev/null +++ b/src/custom_response.ts @@ -0,0 +1,40 @@ +import type { Buffer } from 'node:buffer'; +import type http from 'node:http'; + +export interface CustomResponse { + statusCode?: number; + headers?: Record; + body?: string | Buffer; + encoding?: BufferEncoding; +} + +export interface HandlerOpts { + customResponseFunction: () => CustomResponse | Promise, +} + +export const handleCustomResponse = async ( + _request: http.IncomingMessage, + response: http.ServerResponse, + handlerOpts: HandlerOpts, +): Promise => { + const { customResponseFunction } = handlerOpts; + if (!customResponseFunction) { + throw new Error('The "customResponseFunction" option is required'); + } + + const customResponse = await customResponseFunction(); + + if (typeof customResponse !== 'object' || customResponse === null) { + throw new Error('The user-provided "customResponseFunction" must return an object.'); + } + + response.statusCode = customResponse.statusCode || 200; + + if (customResponse.headers) { + for (const [key, value] of Object.entries(customResponse.headers)) { + response.setHeader(key, value as string); + } + } + + response.end(customResponse.body, customResponse.encoding!); +}; diff --git a/src/direct.ts b/src/direct.ts new file mode 100644 index 00000000..b81335dd --- /dev/null +++ b/src/direct.ts @@ -0,0 +1,111 @@ +import type { Buffer } from 'node:buffer'; +import type dns from 'node:dns'; +import type { EventEmitter } from 'node:events'; +import net from 'node:net'; +import { URL } from 'node:url'; + +import type { Socket } from './socket.js'; +import { countTargetBytes } from './utils/count_target_bytes.js'; + +export interface HandlerOpts { + localAddress?: string; + ipFamily?: number; + dnsLookup?: typeof dns['lookup']; +} + +interface DirectOpts { + request: { url?: string }; + sourceSocket: Socket; + head: Buffer; + server: EventEmitter & { log: (connectionId: unknown, str: string) => void }; + handlerOpts: HandlerOpts; +} + +/** + * Directly connects to the target. + * Client -> Apify (CONNECT) -> Web + * Client <- Apify (CONNECT) <- Web + */ +export const direct = ( + { + request, + sourceSocket, + head, + server, + handlerOpts, + }: DirectOpts, +): void => { + const url = new URL(`connect://${request.url}`); + + if (!url.hostname) { + throw new Error('Missing CONNECT hostname'); + } + + if (!url.port) { + throw new Error('Missing CONNECT port'); + } + + if (head.length > 0) { + // See comment in chain.ts + sourceSocket.unshift(head); + } + + const options = { + port: Number(url.port), + host: url.hostname, + localAddress: handlerOpts.localAddress, + family: handlerOpts.ipFamily, + lookup: handlerOpts.dnsLookup, + }; + + if (options.host[0] === '[') { + options.host = options.host.slice(1, -1); + } + + const targetSocket = net.createConnection(options, () => { + try { + sourceSocket.write(`HTTP/1.1 200 Connection Established\r\n\r\n`); + } catch (error) { + sourceSocket.destroy(error as Error); + } + }); + + countTargetBytes(sourceSocket, targetSocket); + + sourceSocket.pipe(targetSocket); + targetSocket.pipe(sourceSocket); + + // Once target socket closes forcibly, the source socket gets paused. + // We need to enable flowing, otherwise the socket would remain open indefinitely. + // Nothing would consume the data, we just want to close the socket. + targetSocket.on('close', () => { + sourceSocket.resume(); + + if (sourceSocket.writable) { + sourceSocket.end(); + } + }); + + // Same here. + sourceSocket.on('close', () => { + targetSocket.resume(); + + if (targetSocket.writable) { + targetSocket.end(); + } + }); + + const { proxyChainId } = sourceSocket; + + targetSocket.on('error', (error) => { + server.log(proxyChainId, `Direct Destination Socket Error: ${error.stack}`); + + sourceSocket.destroy(); + }); + + sourceSocket.on('error', (error) => { + server.log(proxyChainId, `Direct Source Socket Error: ${error.stack}`); + + targetSocket.destroy(); + }); +}; diff --git a/src/forward.ts b/src/forward.ts new file mode 100644 index 00000000..fb4d6ee6 --- /dev/null +++ b/src/forward.ts @@ -0,0 +1,152 @@ +import type dns from 'node:dns'; +import http from 'node:http'; +import https from 'node:https'; +import { pipeline } from 'node:stream/promises'; +import type { URL } from 'node:url'; + +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'; + +interface Options { + method: string; + headers: string[]; + insecureHTTPParser: boolean; + path?: string; + localAddress?: string; + family?: number; + lookup?: typeof dns['lookup']; +} + +export interface HandlerOpts { + upstreamProxyUrlParsed: URL; + ignoreUpstreamProxyCertificate: boolean; + localAddress?: string; + ipFamily?: number; + dnsLookup?: typeof dns['lookup']; + httpAgent?: http.Agent; + httpsAgent?: https.Agent; +} + +/** + * The request is read from the client and is resent. + * This is similar to Direct / Chain, however it uses the CONNECT protocol instead. + * Forward uses standard HTTP methods. + * + * ``` + * Client -> Apify (HTTP) -> Web + * Client <- Apify (HTTP) <- Web + * ``` + * + * or + * + * ``` + * Client -> Apify (HTTP) -> Upstream (HTTP) -> Web + * Client <- Apify (HTTP) <- Upstream (HTTP) <- Web + * ``` + */ +export const forward = async ( + request: http.IncomingMessage, + response: http.ServerResponse, + handlerOpts: HandlerOpts, + // eslint-disable-next-line no-async-promise-executor +): Promise => new Promise(async (resolve, reject) => { + const proxy = handlerOpts.upstreamProxyUrlParsed; + const origin = proxy ? proxy.origin : request.url; + + const options: Options = { + method: request.method!, + headers: validHeadersOnly(request.rawHeaders), + insecureHTTPParser: true, + localAddress: handlerOpts.localAddress, + family: handlerOpts.ipFamily, + lookup: handlerOpts.dnsLookup, + }; + + // In case of proxy the path needs to be an absolute URL + if (proxy) { + options.path = request.url; + + try { + if (proxy.username || proxy.password) { + options.headers.push('proxy-authorization', getBasicAuthorizationHeader(proxy)); + } + } catch (error) { + reject(error); + return; + } + } + + const requestCallback = async (clientResponse: http.IncomingMessage) => { + try { + // This is necessary to prevent Node.js throwing an error + let statusCode = clientResponse.statusCode!; + if (statusCode < 100 || statusCode > 999) { + statusCode = badGatewayStatusCodes.STATUS_CODE_OUT_OF_RANGE; + } + + // 407 is handled separately + if (clientResponse.statusCode === 407) { + reject(new Error('407 Proxy Authentication Required')); + return; + } + + response.writeHead( + statusCode, + clientResponse.statusMessage, + validHeadersOnly(clientResponse.rawHeaders), + ); + + // `pipeline` automatically handles all the events and data + await pipeline( + clientResponse, + response, + ); + + resolve(); + } catch { + // Client error, pipeline already destroys the streams, ignore. + resolve(); + } + }; + + // We have to force cast `options` because @types/node doesn't support an array. + const client = origin!.startsWith('https:') + ? https.request(origin!, { + ...options as unknown as https.RequestOptions, + rejectUnauthorized: handlerOpts.upstreamProxyUrlParsed ? !handlerOpts.ignoreUpstreamProxyCertificate : undefined, + agent: handlerOpts.httpsAgent, + }, requestCallback) + + : http.request(origin!, { + ...options as unknown as http.RequestOptions, + agent: handlerOpts.httpAgent, + }, requestCallback); + + client.once('socket', (socket: SocketWithPreviousStats) => { + // Socket can be re-used by multiple requests. + // That's why we need to track the previous stats. + socket.previousBytesRead = socket.bytesRead; + socket.previousBytesWritten = socket.bytesWritten; + countTargetBytes(request.socket, socket, (handler) => response.once('close', handler)); + }); + + // Can't use pipeline here as it automatically destroys the streams + request.pipe(client); + client.on('error', (error: NodeJS.ErrnoException) => { + if (response.headersSent) { + resolve(); + return; + } + + const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR; + + response.statusCode = !proxy && error.code === 'ENOTFOUND' ? 404 : statusCode; + response.setHeader('content-type', 'text/plain; charset=utf-8'); + response.end(http.STATUS_CODES[response.statusCode]); + + resolve(); + }); +}); diff --git a/src/forward_socks.ts b/src/forward_socks.ts new file mode 100644 index 00000000..60a0f0b4 --- /dev/null +++ b/src/forward_socks.ts @@ -0,0 +1,108 @@ +import http from 'node:http'; +import { pipeline } from 'node:stream/promises'; +import type { URL } from 'node:url'; + +import { SocksProxyAgent } from 'socks-proxy-agent'; + +import { badGatewayStatusCodes, errorCodeToStatusCode } from './statuses.js'; +import { countTargetBytes } from './utils/count_target_bytes.js'; +import { validHeadersOnly } from './utils/valid_headers_only.js'; + +interface Options { + method: string; + headers: string[]; + insecureHTTPParser: boolean; + path?: string; + localAddress?: string; + agent: http.Agent; +} + +export interface HandlerOpts { + upstreamProxyUrlParsed: URL; + localAddress?: string; +} + +/** + * Forwards HTTP requests through a SOCKS upstream proxy. + * + * **Note:** Custom HTTP/HTTPS agents (`httpAgent`, `httpsAgent`) from `prepareRequestFunction` + * are not supported with SOCKS upstream proxies. SOCKS uses direct socket connections + * managed by the SocksProxyAgent, which does not utilize HTTP agents. + * + * ``` + * Client -> Apify (HTTP) -> Upstream (SOCKS) -> Web + * Client <- Apify (HTTP) <- Upstream (SOCKS) <- Web + * ``` + */ +export const forwardSocks = async ( + request: http.IncomingMessage, + response: http.ServerResponse, + handlerOpts: HandlerOpts, + // eslint-disable-next-line no-async-promise-executor +): Promise => new Promise(async (resolve, reject) => { + const agent = new SocksProxyAgent(handlerOpts.upstreamProxyUrlParsed); + + const options: Options = { + method: request.method!, + headers: validHeadersOnly(request.rawHeaders), + insecureHTTPParser: true, + localAddress: handlerOpts.localAddress, + agent, + }; + + // Only handling "http" here - since everything else is handeled by tunnelSocks. + // We have to force cast `options` because @types/node doesn't support an array. + const client = http.request(request.url!, options as unknown as http.ClientRequestArgs, async (clientResponse) => { + try { + // This is necessary to prevent Node.js throwing an error + let statusCode = clientResponse.statusCode!; + if (statusCode < 100 || statusCode > 999) { + statusCode = badGatewayStatusCodes.STATUS_CODE_OUT_OF_RANGE; + } + + // 407 is handled separately + if (clientResponse.statusCode === 407) { + reject(new Error('407 Proxy Authentication Required')); + return; + } + + response.writeHead( + statusCode, + clientResponse.statusMessage, + validHeadersOnly(clientResponse.rawHeaders), + ); + + // `pipeline` automatically handles all the events and data + await pipeline( + clientResponse, + response, + ); + + resolve(); + } catch { + // Client error, pipeline already destroys the streams, ignore. + resolve(); + } + }); + + client.once('socket', (socket) => { + countTargetBytes(request.socket, socket); + }); + + // Can't use pipeline here as it automatically destroys the streams + request.pipe(client); + client.on('error', (error: NodeJS.ErrnoException) => { + if (response.headersSent) { + resolve(); + return; + } + + const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR; + + response.statusCode = statusCode; + response.setHeader('content-type', 'text/plain; charset=utf-8'); + response.end(http.STATUS_CODES[response.statusCode]); + + resolve(); + }); +}); diff --git a/src/handler_base.js b/src/handler_base.js deleted file mode 100644 index fa3c8eaf..00000000 --- a/src/handler_base.js +++ /dev/null @@ -1,291 +0,0 @@ -import http from 'http'; -import EventEmitter from 'events'; -import { RequestError } from './server'; - -/** - * Base class for proxy connection handlers. It emits the `destroyed` event - * when the handler is no longer used. - */ -export default class HandlerBase extends EventEmitter { - constructor({ - server, id, srcRequest, srcHead, srcResponse, trgParsed, upstreamProxyUrlParsed, - }) { - super(); - - if (!server) throw new Error('The "server" option is required'); - if (!id) throw new Error('The "id" option is required'); - if (!srcRequest) throw new Error('The "srcRequest" option is required'); - if (!srcRequest.socket) throw new Error('"srcRequest.socket" cannot be null'); - if (!trgParsed.hostname) throw new Error('The "trgParsed.hostname" option is required'); - - this.server = server; - this.id = id; - - this.srcRequest = srcRequest; - this.srcHead = srcHead; - this.srcResponse = srcResponse; - this.srcSocket = srcRequest.socket; - - this.trgRequest = null; - this.trgSocket = null; - this.trgParsed = trgParsed; - this.trgParsed.port = this.trgParsed.port || DEFAULT_TARGET_PORT; - - // Indicates that source socket might have received some data already - this.srcGotResponse = false; - - this.isClosed = false; - - this.upstreamProxyUrlParsed = upstreamProxyUrlParsed; - - // Create ServerResponse for the client HTTP request if it doesn't exist - // NOTE: This is undocumented API, it might break in the future - if (!this.srcResponse) { - this.srcResponse = new http.ServerResponse(srcRequest); - this.srcResponse.shouldKeepAlive = false; - this.srcResponse.chunkedEncoding = false; - this.srcResponse.useChunkedEncodingByDefault = false; - this.srcResponse.assignSocket(this.srcSocket); - } - - // Bind all event handlers to this instance - this.bindHandlersToThis([ - 'onSrcResponseFinish', 'onSrcResponseError', - 'onSrcSocketEnd', 'onSrcSocketFinish', 'onSrcSocketClose', 'onSrcSocketError', - 'onTrgSocket', 'onTrgSocketEnd', 'onTrgSocketFinish', 'onTrgSocketClose', 'onTrgSocketError', - ]); - - this.srcResponse.on('error', this.onSrcResponseError); - - // Called for the ServerResponse's "finish" event - // Normally, Node's "http" module has a "finish" event listener that would - // take care of closing the socket once the HTTP response has completed, but - // since we're making this ServerResponse instance manually, that event handler - // never gets hooked up, so we must manually close the socket... - this.srcResponse.on('finish', this.onSrcResponseFinish); - - // Forward data directly to source client without any delay - this.srcSocket.setNoDelay(); - - this.srcSocket.on('end', this.onSrcSocketEnd); - this.srcSocket.on('close', this.onSrcSocketClose); - this.srcSocket.on('finish', this.onSrcSocketFinish); - this.srcSocket.on('error', this.onSrcSocketError); - } - - bindHandlersToThis(handlerNames) { - handlerNames.forEach((evt) => { - this[evt] = this[evt].bind(this); - }); - } - - log(str) { - this.server.log(this.id, str); - } - - // Abstract method, needs to be overridden - run() {} // eslint-disable-line - - onSrcSocketEnd() { - if (this.isClosed) return; - this.log('Source socket ended'); - this.close(); - } - - // On Node 10+, the 'close' event is called only after socket is destroyed, - // so we also need to listen for the stream 'finish' event - onSrcSocketFinish() { - if (this.isClosed) return; - this.log('Source socket finished'); - this.close(); - } - - // If the client closes the connection prematurely, - // then immediately destroy the upstream socket, there's nothing we can do with it - onSrcSocketClose() { - if (this.isClosed) return; - this.log('Source socket closed'); - this.close(); - } - - onSrcSocketError(err) { - if (this.isClosed) return; - this.log(`Source socket failed: ${err.stack || err}`); - this.close(); - } - - // This is to address https://github.com/apify/proxy-chain/issues/27 - // It seems that when client closed the connection, the piped target socket - // can still pump data to it, which caused unhandled "write after end" error - onSrcResponseError(err) { - if (this.isClosed) return; - this.log(`Source response failed: ${err.stack || err}`); - this.close(); - } - - onSrcResponseFinish() { - if (this.isClosed) return; - this.log('Source response finished, ending source socket'); - // NOTE: We cannot destroy the socket, since there might be pending data that wouldn't be delivered! - // This code is inspired by resOnFinish() in _http_server.js in Node.js code base. - if (typeof this.srcSocket.destroySoon === 'function') { - this.srcSocket.destroySoon(); - } else { - this.srcSocket.end(); - } - } - - onTrgSocket(socket) { - if (this.isClosed || this.trgSocket) return; - this.log('Target socket assigned'); - - this.trgSocket = socket; - - // Forward data directly to target server without any delay - this.trgSocket.setNoDelay(); - - socket.on('end', this.onTrgSocketEnd); - socket.on('finish', this.onTrgSocketFinish); - socket.on('close', this.onTrgSocketClose); - socket.on('error', this.onTrgSocketError); - } - - trgSocketShutdown(msg) { - if (this.isClosed) return; - this.log(msg); - // Once target socket closes, we need to give time - // to source socket to receive pending data, so we only call end() - // If socket is closed here instead of response, phantomjs does not properly parse the response as http response. - if (this.srcResponse) { - this.srcResponse.end(); - } else if (this.srcSocket) { - // Handler tunnel chain does not use srcResponse, but needs to close srcSocket - this.srcSocket.end(); - } - } - - onTrgSocketEnd() { - this.trgSocketShutdown('Target socket ended'); - } - - onTrgSocketFinish() { - this.trgSocketShutdown('Target socket finished'); - } - - onTrgSocketClose() { - this.trgSocketShutdown('Target socket closed'); - } - - onTrgSocketError(err) { - if (this.isClosed) return; - this.log(`Target socket failed: ${err.stack || err}`); - this.fail(err); - } - - /** - * Checks whether response from upstream proxy is 407 Proxy Authentication Required - * and if so, responds 502 Bad Gateway to client. - * @param response - * @return {boolean} - */ - checkUpstreamProxy407(response) { - if (this.upstreamProxyUrlParsed && response.statusCode === 407) { - this.fail(new RequestError('Invalid credentials provided for the upstream proxy.', 502)); - return true; - } - return false; - } - - fail(err) { - if (this.srcGotResponse) { - this.log('Source already received a response, just destroying the socket...'); - this.close(); - return; - } - - this.srcGotResponse = true; - this.srcResponse.setHeader('Content-Type', 'text/plain; charset=utf-8'); - - if (err.statusCode) { - // Error is RequestError with HTTP status code - this.log(`${err} Responding with custom status code ${err.statusCode} to client`); - this.srcResponse.writeHead(err.statusCode); - this.srcResponse.end(`${err.message}`); - } else if (err.code === 'ENOTFOUND' && !this.upstreamProxyUrlParsed) { - this.log('Target server not found, sending 404 to client'); - this.srcResponse.writeHead(404); - this.srcResponse.end('Target server not found'); - } else if (err.code === 'ENOTFOUND' && this.upstreamProxyUrlParsed) { - this.log('Upstream proxy not found, sending 502 to client'); - this.srcResponse.writeHead(502); - this.srcResponse.end('Upstream proxy was not found'); - } else if (err.code === 'ECONNREFUSED') { - this.log('Upstream proxy refused connection, sending 502 to client'); - this.srcResponse.writeHead(502); - this.srcResponse.end('Upstream proxy refused connection'); - } else if (err.code === 'ETIMEDOUT') { - this.log('Connection timed out, sending 502 to client'); - this.srcResponse.writeHead(502); - this.srcResponse.end('Connection to upstream proxy timed out'); - } else if (err.code === 'ECONNRESET') { - this.log('Connection lost, sending 502 to client'); - this.srcResponse.writeHead(502); - this.srcResponse.end('Connection lost'); - } else if (err.code === 'EPIPE') { - this.log('Socket closed before write, sending 502 to client'); - this.srcResponse.writeHead(502); - this.srcResponse.end('Connection interrupted'); - } else { - this.log('Unknown error, sending 500 to client'); - this.srcResponse.writeHead(500); - this.srcResponse.end('Internal error in proxy server'); - } - - // No need to call `this.close()` here, - // destruction is handled by `onSrcResponseFinish` - } - - getStats() { - return { - srcTxBytes: this.srcSocket ? this.srcSocket.bytesWritten : null, - srcRxBytes: this.srcSocket ? this.srcSocket.bytesRead : null, - trgTxBytes: this.trgSocket ? this.trgSocket.bytesWritten : null, - trgRxBytes: this.trgSocket ? this.trgSocket.bytesRead : null, - }; - } - - /** - * Detaches all listeners, destroys all sockets and emits the 'close' event. - */ - close() { - if (this.isClosed) return; - - this.log('Closing handler'); - this.isClosed = true; - - // Save stats before sockets are destroyed - const stats = this.getStats(); - - if (this.srcRequest) { - this.srcRequest.destroy(); - this.srcRequest = null; - } - - if (this.srcSocket) { - this.srcSocket.destroy(); - this.srcSocket = null; - } - - if (this.trgRequest) { - this.trgRequest.abort(); - this.trgRequest = null; - } - - if (this.trgSocket) { - this.trgSocket.destroy(); - this.trgSocket = null; - } - - this.emit('close', { stats }); - } -} diff --git a/src/handler_custom_response.js b/src/handler_custom_response.js deleted file mode 100644 index 7a1afafc..00000000 --- a/src/handler_custom_response.js +++ /dev/null @@ -1,62 +0,0 @@ -import _ from 'underscore'; -import HandlerBase from './handler_base'; - -/** - * Represents a proxied HTTP request for which the response is generated by custom user function. - * This is useful e.g. when providing access to external APIs via HTTP proxy interface. - */ -export default class HandlerCustomResponse extends HandlerBase { - constructor(options) { - super(options); - - this.customResponseFunction = options.customResponseFunction; - if (!this.customResponseFunction) throw new Error('The "customResponseFunction" option is required'); - } - - run() { - const reqOpts = this.trgParsed; - reqOpts.method = this.srcRequest.method; - reqOpts.headers = {}; - - Promise.resolve() - .then(() => { - return this.customResponseFunction(); - }) - .then((customResponse) => { - if (this.isClosed) return; - - if (!customResponse) { - throw new Error('The user-provided "customResponseFunction" must return an object.'); - } - - const statusCode = customResponse.statusCode || 200; - const length = customResponse.body ? customResponse.body.length : null; - - this.log(`Received custom user response (${statusCode}, length: ${length}, encoding: ${customResponse.encoding})`); - - // Forward custom response to source - this.srcGotResponse = true; - this.srcResponse.statusCode = statusCode; - - _.each(customResponse.headers, (value, key) => { - this.srcResponse.setHeader(key, value); - }); - - return new Promise((resolve, reject) => { - this.srcGotResponse = true; - this.srcResponse.end(customResponse.body, customResponse.encoding, (err) => { - if (err) return reject(err); - resolve(); - }); - }); - }) - .then(() => { - this.log('Custom response sent to source'); - }) - .catch((err) => { - if (this.isClosed) return; - this.log(`Custom response function failed: ${err.stack || err}`); - this.fail(err); - }); - } -} diff --git a/src/handler_forward.js b/src/handler_forward.js deleted file mode 100644 index bb5c6f0d..00000000 --- a/src/handler_forward.js +++ /dev/null @@ -1,197 +0,0 @@ -import http from 'http'; -import { - isHopByHopHeader, isInvalidHeader, addHeader, maybeAddProxyAuthorizationHeader, -} from './tools'; -import HandlerBase from './handler_base'; -import { RequestError } from './server'; - -/** - * Represents a proxied request to a HTTP server, either direct or via another upstream proxy. - */ -export default class HandlerForward extends HandlerBase { - constructor(options) { - super(options); - - this.bindHandlersToThis(['onTrgResponse', 'onTrgError']); - } - - run() { - const reqOpts = this.trgParsed; - reqOpts.method = this.srcRequest.method; - reqOpts.headers = {}; - - // TODO: - // - We should probably use a raw HTTP message via socket instead of http.request(), - // since Node transforms the headers to lower case and thus makes it easy to detect the proxy - // - The "Connection" header might define additional hop-by-hop headers that should be removed, - // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection - // - We should also add "Via" and "X-Forwarded-For" headers - // - Or, alternatively, we should make this proxy fully transparent - - let hostHeaderFound = false; - - for (let i = 0; i < this.srcRequest.rawHeaders.length; i += 2) { - const headerName = this.srcRequest.rawHeaders[i]; - const headerValue = this.srcRequest.rawHeaders[i + 1]; - - if (/^connection$/i.test(headerName) && /^keep-alive$/i.test(headerValue)) { - // Keep the "Connection: keep-alive" header, to reduce the chance that the server - // will detect we're not a browser and also to improve performance - } else if (isHopByHopHeader(headerName)) { - continue; - } else if (isInvalidHeader(headerName, headerValue)) { - continue; - } else if (/^host$/i.test(headerName)) { - // If Host header was used multiple times, only consider the first one. - // This is to prevent "TypeError: hostHeader.startsWith is not a function at calculateServerName (_http_agent.js:240:20)" - if (hostHeaderFound) continue; - hostHeaderFound = true; - } - - /* - if (!hasXForwardedFor && 'x-forwarded-for' === keyLower) { - // append to existing "X-Forwarded-For" header - // http://en.wikipedia.org/wiki/X-Forwarded-For - hasXForwardedFor = true; - value += ', ' + socket.remoteAddress; - debug.proxyRequest('appending to existing "%s" header: "%s"', key, value); - } - - if (!hasVia && 'via' === keyLower) { - // append to existing "Via" header - hasVia = true; - value += ', ' + via; - debug.proxyRequest('appending to existing "%s" header: "%s"', key, value); - } - */ - - addHeader(reqOpts.headers, headerName, headerValue); - } - - /* - // add "X-Forwarded-For" header if it's still not here by now - // http://en.wikipedia.org/wiki/X-Forwarded-For - if (!hasXForwardedFor) { - headers['X-Forwarded-For'] = socket.remoteAddress; - debug.proxyRequest('adding new "X-Forwarded-For" header: "%s"', headers['X-Forwarded-For']); - } - - // add "Via" header if still not set by now - if (!hasVia) { - headers.Via = via; - debug.proxyRequest('adding new "Via" header: "%s"', headers.Via); - } - - // custom `http.Agent` support, set `server.agent` - var agent = server.agent; - if (null != agent) { - debug.proxyRequest('setting custom `http.Agent` option for proxy request: %s', agent); - parsed.agent = agent; - agent = null; - } - */ - - // If desired, send the request via proxy - if (this.upstreamProxyUrlParsed) { - reqOpts.host = this.upstreamProxyUrlParsed.hostname; - reqOpts.hostname = reqOpts.host; - reqOpts.port = this.upstreamProxyUrlParsed.port; - - // HTTP requests to proxy contain the full URL in path, for example: - // "GET http://www.example.com HTTP/1.1\r\n" - // So we need to replicate it here - reqOpts.path = this.srcRequest.url; - - maybeAddProxyAuthorizationHeader(this.upstreamProxyUrlParsed, reqOpts.headers); - - this.log(`Connecting to upstream proxy ${reqOpts.host}:${reqOpts.port}`); - } else { - this.log(`Connecting to target ${reqOpts.host}`); - } - - // console.dir(requestOptions); - - this.trgRequest = http.request(reqOpts); - this.trgRequest.on('socket', this.onTrgSocket); - this.trgRequest.on('response', this.onTrgResponse); - this.trgRequest.on('error', this.onTrgError); - - // this.srcRequest.pipe(tee('to trg')).pipe(this.trgRequest); - this.srcRequest.pipe(this.trgRequest); - } - - onTrgResponse(response) { - if (this.isClosed) return; - this.log(`Received response from target (${response.statusCode})`); - - if (this.checkUpstreamProxy407(response)) return; - - // Prepare response headers - const headers = {}; - for (let i = 0; i < response.rawHeaders.length; i += 2) { - const name = response.rawHeaders[i]; - const value = response.rawHeaders[i + 1]; - - if (isHopByHopHeader(name)) continue; - if (isInvalidHeader(name, value)) continue; - - addHeader(headers, name, value); - } - - // Ensure status code is in the range accepted by Node, otherwise proxy will crash with - // "RangeError: Invalid status code: 0" (see writeHead in Node's _http_server.js) - // Fixes https://github.com/apify/proxy-chain/issues/35 - if (response.statusCode < 100 || response.statusCode > 999) { - this.fail(new RequestError(`Target server responded with an invalid HTTP status code (${response.statusCode})`, 500)); - return; - } - - this.srcGotResponse = true; - - // Note that sockets could be closed anytime, causing this.close() to be called too in above statements - // See https://github.com/apify/proxy-chain/issues/64 - if (this.isClosed) return; - - this.srcResponse.writeHead(response.statusCode, headers); - response.pipe(this.srcResponse); - - // Only detach on success, if there's an error - // it will be handled by `onTrgError` which calls `fail`, - // which forces the socket to disconnect. - response.once('end', () => { - this.detach(); - }); - } - - onTrgError(err) { - if (this.isClosed) return; - this.log(`Target socket failed: ${err.stack || err}`); - this.fail(err); - } - - /** - * @see https://github.com/apify/proxy-chain/issues/81 - * Detach removes all listeners registered by HandlerBase. - * Must be called when the handler finishes on success, - * in order to prevent event emitter memory leak. - */ - detach() { - if (this.isClosed) return; - - this.log('Closing handler (detach)'); - this.isClosed = true; - - // Save stats before sockets are destroyed - const stats = this.getStats(); - - this.srcSocket.off('end', this.onSrcSocketEnd); - this.srcSocket.off('close', this.onSrcSocketClose); - this.srcSocket.off('finish', this.onSrcSocketFinish); - this.srcSocket.off('error', this.onSrcSocketError); - - this.srcResponse.off('error', this.onSrcResponseError); - this.srcResponse.off('finish', this.onSrcResponseFinish); - - this.emit('close', { stats }); - } -} diff --git a/src/handler_tunnel_chain.js b/src/handler_tunnel_chain.js deleted file mode 100644 index 78d64f6e..00000000 --- a/src/handler_tunnel_chain.js +++ /dev/null @@ -1,108 +0,0 @@ -import http from 'http'; -import HandlerBase from './handler_base'; -import { maybeAddProxyAuthorizationHeader } from './tools'; - -/** - * Represents a connection from source client to an external proxy using HTTP CONNECT tunnel. - */ -export default class HandlerTunnelChain extends HandlerBase { - constructor(options) { - super(options); - - if (!this.upstreamProxyUrlParsed) throw new Error('The "upstreamProxyUrlParsed" option is required'); - - this.bindHandlersToThis(['onTrgRequestConnect', 'onTrgRequestAbort', 'onTrgRequestError']); - } - - run() { - this.log('Connecting to upstream proxy...'); - - const targetHost = `${this.trgParsed.hostname}:${this.trgParsed.port}`; - - const options = { - method: 'CONNECT', - hostname: this.upstreamProxyUrlParsed.hostname, - port: this.upstreamProxyUrlParsed.port, - path: targetHost, - headers: { - Host: targetHost, - }, - }; - - maybeAddProxyAuthorizationHeader(this.upstreamProxyUrlParsed, options.headers); - - this.trgRequest = http.request(options); - - this.trgRequest.on('socket', this.onTrgSocket); - this.trgRequest.on('connect', this.onTrgRequestConnect); - this.trgRequest.on('abort', this.onTrgRequestAbort); - this.trgRequest.on('error', this.onTrgRequestError); - - // Send the data - this.trgRequest.end(); - } - - onTrgRequestConnect(response, socket, head) { - if (this.isClosed) return; - this.log('Connected to upstream proxy'); - - // Attempt to fix https://github.com/apify/proxy-chain/issues/64, - // perhaps the 'connect' event might occur before 'socket' - if (!this.trgSocket) { - this.onTrgSocket(socket); - } - - if (this.checkUpstreamProxy407(response)) return; - - this.srcGotResponse = true; - this.srcResponse.removeListener('finish', this.onSrcResponseFinish); - this.srcResponse.writeHead(200, 'Connection Established'); - - this.emit('tunnelConnectResponded', { response, socket, head }); - - // HACK: force a flush of the HTTP header. This is to ensure 'head' is empty to avoid - // assert at https://github.com/request/tunnel-agent/blob/master/index.js#L160 - // See also https://github.com/nodejs/node/blob/master/lib/_http_outgoing.js#L217 - this.srcResponse._send(''); - - // It can happen that this.close() it called in the meanwhile, so this.srcSocket becomes null - // and the detachSocket() call below fails with "Cannot read property '_httpMessage' of null" - // See https://github.com/apify/proxy-chain/issues/63 - if (this.isClosed) return; - - // Relinquish control of the `socket` from the ServerResponse instance - this.srcResponse.detachSocket(this.srcSocket); - - // Nullify the ServerResponse object, so that it can be cleaned - // up before this socket proxying is completed - this.srcResponse = null; - - // Forward pre-parsed parts of the first packets (if any) - if (head && head.length > 0) { - this.srcSocket.write(head); - } - if (this.srcHead && this.srcHead.length > 0) { - this.trgSocket.write(this.srcHead); - } - - // Note that sockets could be closed anytime, causing this.close() to be called too in above statements - // See https://github.com/apify/proxy-chain/issues/64 - if (this.isClosed) return; - - // Setup bi-directional tunnel - this.trgSocket.pipe(this.srcSocket); - this.srcSocket.pipe(this.trgSocket); - } - - onTrgRequestAbort() { - if (this.isClosed) return; - this.log('Target aborted'); - this.close(); - } - - onTrgRequestError(err) { - if (this.isClosed) return; - this.log(`Target request failed: ${err.stack || err}`); - this.fail(err); - } -} diff --git a/src/handler_tunnel_direct.js b/src/handler_tunnel_direct.js deleted file mode 100644 index 926688bb..00000000 --- a/src/handler_tunnel_direct.js +++ /dev/null @@ -1,64 +0,0 @@ -import net from 'net'; -import HandlerBase from './handler_base'; - -/** - * Represents a proxied connection from source to the target HTTPS server. - */ -export default class HandlerTunnelDirect extends HandlerBase { - constructor(options) { - super(options); - - this.bindHandlersToThis(['onTrgSocketConnect']); - } - - run() { - this.log(`Connecting to target ${this.trgParsed.hostname}:${this.trgParsed.port}`); - - const socket = net.createConnection(this.trgParsed.port, this.trgParsed.hostname); - this.onTrgSocket(socket); - - socket.on('connect', this.onTrgSocketConnect); - } - - onTrgSocketConnect(response, socket, head) { - if (this.isClosed) return; - this.log('Connected'); - - this.srcGotResponse = true; - - this.srcResponse.removeListener('finish', this.onSrcResponseFinish); - this.srcResponse.writeHead(200, 'Connection Established'); - - // HACK: force a flush of the HTTP header. This is to ensure 'head' is empty to avoid - // assert at https://github.com/request/tunnel-agent/blob/master/index.js#L160 - // See also https://github.com/nodejs/node/blob/master/lib/_http_outgoing.js#L217 - this.srcResponse._send(''); - - // It can happen that this.close() it called in the meanwhile, so this.srcSocket becomes null - // and the detachSocket() call below fails with "Cannot read property '_httpMessage' of null" - // See https://github.com/apify/proxy-chain/issues/63 - if (this.isClosed) return; - - // Relinquish control of the socket from the ServerResponse instance - this.srcResponse.detachSocket(this.srcSocket); - - // ServerResponse is no longer needed - this.srcResponse = null; - - // Forward pre-parsed parts of the first packets (if any) - if (head && head.length > 0) { - this.srcSocket.write(head); - } - if (this.srcHead && this.srcHead.length > 0) { - this.trgSocket.write(this.srcHead); - } - - // Note that sockets could be closed anytime, causing this.close() to be called too in above statements - // See https://github.com/apify/proxy-chain/issues/64 - if (this.isClosed) return; - - // Setup bi-directional tunnel - this.trgSocket.pipe(this.srcSocket); - this.srcSocket.pipe(this.trgSocket); - } -} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 6ec829ae..00000000 --- a/src/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import { Server, RequestError } from './server'; -import { parseUrl, redactUrl, redactParsedUrl } from './tools'; -import { anonymizeProxy, closeAnonymizedProxy, listenConnectAnonymizedProxy } from './anonymize_proxy'; -import { createTunnel, closeTunnel } from './tcp_tunnel_tools'; - -// Publicly exported functions and classes -const ProxyChain = { - Server, - RequestError, - parseUrl, - redactUrl, - redactParsedUrl, - anonymizeProxy, - closeAnonymizedProxy, - listenConnectAnonymizedProxy, - createTunnel, - closeTunnel, -}; - -module.exports = ProxyChain; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..54867287 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,7 @@ +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 type { CustomResponse } from './custom_response.js'; diff --git a/src/request_error.ts b/src/request_error.ts new file mode 100644 index 00000000..d6044aee --- /dev/null +++ b/src/request_error.ts @@ -0,0 +1,20 @@ +/** + * Represents custom request error. The message is emitted as HTTP response + * with a specific HTTP code and headers. + * If this error is thrown from the `prepareRequestFunction` function, + * the message and status code is sent to client. + * By default, the response will have Content-Type: text/plain + * and for the 407 status the Proxy-Authenticate header will be added. + */ +export class RequestError extends Error { + constructor( + message: string, + public statusCode: number, + public headers?: Record, + ) { + super(message); + this.name = RequestError.name; + + Error.captureStackTrace(this, RequestError); + } +} diff --git a/src/server.js b/src/server.js deleted file mode 100644 index 39a72cb0..00000000 --- a/src/server.js +++ /dev/null @@ -1,539 +0,0 @@ -import http from 'http'; -import util from 'util'; -import EventEmitter from 'events'; -import _ from 'underscore'; -import { - parseHostHeader, parseProxyAuthorizationHeader, parseUrl, redactParsedUrl, nodeify, -} from './tools'; -import HandlerForward from './handler_forward'; -import HandlerTunnelDirect from './handler_tunnel_direct'; -import HandlerTunnelChain from './handler_tunnel_chain'; -import HandlerCustomResponse from './handler_custom_response'; - -// TODO: -// - Fail gracefully if target proxy fails (invalid credentials or non-existent) -// - Implement this requirement from rfc7230 -// "A proxy MUST forward unrecognized header fields unless the field-name -// is listed in the Connection header field (Section 6.1) or the proxy -// is specifically configured to block, or otherwise transform, such -// fields. Other recipients SHOULD ignore unrecognized header fields. -// These requirements allow HTTP's functionality to be enhanced without -// requiring prior update of deployed intermediaries." -// - Add param to prepareRequestFunction() that would allow the caller to kill a connection - -// TODO: -// - Use connection pooling and maybe other stuff from: -// https://github.com/request/tunnel-agent/blob/master/index.js -// https://github.com/request/request/blob/master/lib/tunnel.js - -const DEFAULT_AUTH_REALM = 'ProxyChain'; -const DEFAULT_PROXY_SERVER_PORT = 8000; -const DEFAULT_TARGET_PORT = 80; - -const REQUEST_ERROR_NAME = 'RequestError'; - -/** - * Represents custom request error. The message is emitted as HTTP response - * with a specific HTTP code and headers. - * If this error is thrown from the `prepareRequestFunction` function, - * the message and status code is sent to client. - * By default, the response will have Content-Type: text/plain - * and for the 407 status the Proxy-Authenticate header will be added. - */ -export class RequestError extends Error { - constructor(message, statusCode, headers) { - super(message); - this.name = REQUEST_ERROR_NAME; - this.statusCode = statusCode; - this.headers = headers; - - Error.captureStackTrace(this, RequestError); - } -} - -/** - * Represents the proxy server. - * It emits the 'requestFailed' event on unexpected request errors, with the following parameter `{ error, request }`. - * It emits the 'connectionClosed' event when connection to proxy server is closed, with parameter `{ connectionId, stats }`. - */ -export class Server extends EventEmitter { - /** - * Initializes a new instance of Server class. - * @param options - * @param [options.port] Port where the server will listen. By default 8000. - * @param [options.prepareRequestFunction] Custom function to authenticate proxy requests, - * provide URL to chained upstream proxy or potentially provide function that generates a custom response to HTTP requests. - * It accepts a single parameter which is an object: - * ```{ - * connectionId: Number, - * request: Object, - * username: String, - * password: String, - * hostname: String, - * port: Number, - * isHttp: Boolean - * }``` - * and returns an object (or promise resolving to the object) with following form: - * ```{ - * requestAuthentication: Boolean, - * upstreamProxyUrl: String, - * customResponseFunction: Function - * }``` - * If `upstreamProxyUrl` is false-ish value, no upstream proxy is used. - * If `prepareRequestFunction` is not set, the proxy server will not require any authentication - * and will not use any upstream proxy. - * If `customResponseFunction` is set, it will be called to generate a custom response to the HTTP request. - * It should not be used together with `upstreamProxyUrl`. - * @param [options.authRealm] Realm used in the Proxy-Authenticate header and also in the 'Server' HTTP header. By default it's `ProxyChain`. - * @param [options.verbose] If true, the server logs - */ - constructor(options) { - super(); - - options = options || {}; - - if (options.port === undefined || options.port === null) { - this.port = DEFAULT_PROXY_SERVER_PORT; - } else { - this.port = options.port; - } - this.prepareRequestFunction = options.prepareRequestFunction; - this.authRealm = options.authRealm || DEFAULT_AUTH_REALM; - this.verbose = !!options.verbose; - - // Key is handler ID, value is HandlerXxx instance - this.handlers = {}; - this.lastHandlerId = 0; - - this.server = http.createServer(); - this.server.on('clientError', this.onClientError.bind(this)); - this.server.on('request', this.onRequest.bind(this)); - this.server.on('connect', this.onConnect.bind(this)); - this.server.on('connection', this.onConnection.bind(this)); - - this.stats = { - httpRequestCount: 0, - connectRequestCount: 0, - }; - } - - log(handlerId, str) { - if (this.verbose) { - const logPrefix = handlerId ? `${handlerId} | ` : ''; - console.log(`ProxyServer[${this.port}]: ${logPrefix}${str}`); - } - } - - onClientError(err, socket) { - this.log(null, `onClientError: ${err}`); - this.sendResponse(socket, 400, null, 'Invalid request'); - } - - /** - * Handles incoming sockets, useful for error handling - */ - onConnection(socket) { - // We need to consume socket errors, otherwise they could crash the entire process. - // See https://github.com/apify/proxy-chain/issues/53 - socket.on('error', (err) => { - // Handle errors only if there's no other handler - if (this.listenerCount('error') === 1) { - this.log(handlerOpts.id, `Source socket emitted error: ${err.stack || err}`); - } - }); - } - - /** - * Handles normal HTTP request by forwarding it to target host or the upstream proxy. - */ - onRequest(request, response) { - let handlerOpts; - this.prepareRequestHandling(request) - .then((result) => { - handlerOpts = result; - handlerOpts.srcResponse = response; - - let handler; - if (handlerOpts.customResponseFunction) { - this.log(handlerOpts.id, 'Using HandlerCustomResponse'); - handler = new HandlerCustomResponse(handlerOpts); - } else { - this.log(handlerOpts.id, 'Using HandlerForward'); - handler = new HandlerForward(handlerOpts); - } - - this.handlerRun(handler); - }) - .catch((err) => { - this.failRequest(request, err, handlerOpts); - }); - } - - /** - * Handles HTTP CONNECT request by setting up a tunnel either to target host or to the upstream proxy. - * @param request - * @param socket - * @param head The first packet of the tunneling stream (may be empty) - */ - onConnect(request, socket, head) { - let handlerOpts; - this.prepareRequestHandling(request) - .then((result) => { - handlerOpts = result; - handlerOpts.srcHead = head; - - let handler; - if (handlerOpts.upstreamProxyUrlParsed) { - this.log(handlerOpts.id, 'Using HandlerTunnelChain'); - handler = new HandlerTunnelChain(handlerOpts); - } else { - this.log(handlerOpts.id, 'Using HandlerTunnelDirect'); - handler = new HandlerTunnelDirect(handlerOpts); - } - - this.handlerRun(handler); - }) - .catch((err) => { - this.failRequest(request, err, handlerOpts); - }); - } - - /** - * Authenticates a new request and determines upstream proxy URL using the user function. - * Returns a promise resolving to an object that can be passed to construcot of one of the HandlerXxx classes. - * @param request - */ - prepareRequestHandling(request) { - // console.log('XXX prepareRequestHandling'); - // console.dir(_.pick(request, 'url', 'method')); - // console.dir(url.parse(request.url)); - - const handlerOpts = { - server: this, - id: ++this.lastHandlerId, - srcRequest: request, - srcHead: null, - trgParsed: null, - upstreamProxyUrlParsed: null, - }; - - this.log(handlerOpts.id, `!!! Handling ${request.method} ${request.url} HTTP/${request.httpVersion}`); - - const { socket } = request; - let isHttp = false; - - return Promise.resolve() - .then(() => { - // console.dir(_.pick(request, 'url', 'headers', 'method')); - // Determine target hostname and port - if (request.method === 'CONNECT') { - // The request should look like: - // CONNECT server.example.com:80 HTTP/1.1 - // Note that request.url contains the "server.example.com:80" part - handlerOpts.trgParsed = parseHostHeader(request.url); - - // If srcRequest.url does not match the regexp tools.HOST_HEADER_REGEX - // or the url is too long it will not be parsed so we throw error here. - if (!handlerOpts.trgParsed) { - throw new RequestError(`Target "${request.url}" could not be parsed`, 400); - } - - this.stats.connectRequestCount++; - } else { - // The request should look like: - // GET http://server.example.com:80/some-path HTTP/1.1 - // Note that RFC 7230 says: - // "When making a request to a proxy, other than a CONNECT or server-wide - // OPTIONS request (as detailed below), a client MUST send the target - // URI in absolute-form as the request-target" - - let parsed; - try { - parsed = parseUrl(request.url); - } catch (e) { - // If URL is invalid, throw HTTP 400 error - throw new RequestError(`Target "${request.url}" could not be parsed`, 400); - } - // If srcRequest.url is something like '/some-path', this is most likely a normal HTTP request - if (!parsed.protocol) { - throw new RequestError('Hey, good try, but I\'m a HTTP proxy, not your ordinary web server :)', 400); - } - // Only HTTP is supported, other protocols such as HTTP or FTP must use the CONNECT method - if (parsed.protocol !== 'http:') { - throw new RequestError(`Only HTTP protocol is supported (was ${parsed.protocol})`, 400); - } - - handlerOpts.trgParsed = parsed; - isHttp = true; - - this.stats.httpRequestCount++; - } - - handlerOpts.trgParsed.port = handlerOpts.trgParsed.port || DEFAULT_TARGET_PORT; - - // Authenticate the request using a user function (if provided) - if (!this.prepareRequestFunction) return { requestAuthentication: false, upstreamProxyUrlParsed: null }; - - // Pause the socket so that no data is lost - socket.pause(); - - const funcOpts = { - connectionId: handlerOpts.id, - request, - username: null, - password: null, - hostname: handlerOpts.trgParsed.hostname, - port: handlerOpts.trgParsed.port, - isHttp, - }; - - const proxyAuth = request.headers['proxy-authorization']; - if (proxyAuth) { - const auth = parseProxyAuthorizationHeader(proxyAuth); - if (!auth) { - throw new RequestError('Invalid "Proxy-Authorization" header', 400); - } - if (auth.type !== 'Basic') { - throw new RequestError('The "Proxy-Authorization" header must have the "Basic" type.', 400); - } - funcOpts.username = auth.username; - funcOpts.password = auth.password; - } - - // User function returns a result directly or a promise - return this.prepareRequestFunction(funcOpts); - }) - .then((funcResult) => { - // If not authenticated, request client to authenticate - if (funcResult && funcResult.requestAuthentication) { - throw new RequestError(funcResult.failMsg || 'Proxy credentials required.', 407); - } - - if (funcResult && funcResult.upstreamProxyUrl) { - try { - handlerOpts.upstreamProxyUrlParsed = parseUrl(funcResult.upstreamProxyUrl); - } catch (e) { - throw new Error(`Invalid "upstreamProxyUrl" provided: ${e} (was "${funcResult.upstreamProxyUrl}"`); - } - - if (!handlerOpts.upstreamProxyUrlParsed.hostname || !handlerOpts.upstreamProxyUrlParsed.port) { - throw new Error(`Invalid "upstreamProxyUrl" provided: URL must have hostname and port (was "${funcResult.upstreamProxyUrl}")`); // eslint-disable-line max-len - } - if (handlerOpts.upstreamProxyUrlParsed.protocol !== 'http:') { - throw new Error(`Invalid "upstreamProxyUrl" provided: URL must have the "http" protocol (was "${funcResult.upstreamProxyUrl}")`); // eslint-disable-line max-len - } - if (/:/.test(handlerOpts.upstreamProxyUrlParsed.username)) { - throw new Error('Invalid "upstreamProxyUrl" provided: The username cannot contain the colon (:) character according to RFC 7617.'); // eslint-disable-line max-len - } - } - - if (funcResult && funcResult.customResponseFunction) { - this.log(handlerOpts.id, 'Using custom response function'); - handlerOpts.customResponseFunction = funcResult.customResponseFunction; - if (!isHttp) { - throw new Error('The "customResponseFunction" option can only be used for HTTP requests.'); - } - if (typeof (handlerOpts.customResponseFunction) !== 'function') { - throw new Error('The "customResponseFunction" option must be a function.'); - } - } - - if (handlerOpts.upstreamProxyUrlParsed) { - this.log(handlerOpts.id, `Using upstream proxy ${redactParsedUrl(handlerOpts.upstreamProxyUrlParsed)}`); - } - - return handlerOpts; - }) - .finally(() => { - if (this.prepareRequestFunction) socket.resume(); - }); - } - - handlerRun(handler) { - this.handlers[handler.id] = handler; - - handler.once('close', ({ stats }) => { - this.emit('connectionClosed', { - connectionId: handler.id, - stats, - }); - delete this.handlers[handler.id]; - this.log(handler.id, '!!! Closed and removed from server'); - }); - - handler.once('tunnelConnectResponded', ({ response, socket, head }) => { - this.emit('tunnelConnectResponded', { - connectionId: handler.id, - response, - socket, - head, - }); - }); - - handler.run(); - } - - /** - * Sends a HTTP error response to the client. - * @param request - * @param err - */ - failRequest(request, err, handlerOpts) { - const handlerId = handlerOpts ? handlerOpts.id : null; - - if (err.name === REQUEST_ERROR_NAME) { - this.log(handlerId, `Request failed (status ${err.statusCode}): ${err.message}`); - this.sendResponse(request.socket, err.statusCode, err.headers, err.message); - } else { - this.log(handlerId, `Request failed with unknown error: ${err.stack || err}`); - this.sendResponse(request.socket, 500, null, 'Internal error in proxy server'); - this.emit('requestFailed', { error: err, request }); - } - - // Emit 'connectionClosed' event if request failed and connection was already reported - if (handlerOpts) { - this.log(handlerId, 'Closed because request failed with error'); - this.emit('connectionClosed', { - connectionId: handlerOpts.id, - stats: { srcTxBytes: 0, srcRxBytes: 0 }, - }); - } - } - - /** - * Sends a simple HTTP response to the client and forcibly closes the connection. - * @param socket - * @param statusCode - * @param headers - * @param message - */ - sendResponse(socket, statusCode, headers, message) { - try { - headers = headers || {}; - - // TODO: We should use fully case-insensitive lookup here! - if (!headers['Content-Type'] && !headers['content-type']) { - headers['Content-Type'] = 'text/plain; charset=utf-8'; - } - if (statusCode === 407 && !headers['Proxy-Authenticate'] && !headers['proxy-authenticate']) { - headers['Proxy-Authenticate'] = `Basic realm="${this.authRealm}"`; - } - if (!headers.Server) { - headers.Server = this.authRealm; - } - // These headers are required by e.g. PhantomJS, otherwise the connection would time out! - if (!headers.Connection) { - headers.Connection = 'close'; - } - if (!headers['Content-Length'] && !headers['content-length']) { - headers['Content-Length'] = Buffer.byteLength(message); - } - - let msg = `HTTP/1.1 ${statusCode} ${http.STATUS_CODES[statusCode]}\r\n`; - _.each(headers, (value, key) => { - msg += `${key}: ${value}\r\n`; - }); - msg += `\r\n${message}`; - - // console.log("RESPONSE:\n" + msg); - - socket.write(msg, () => { - socket.end(); - - // Unfortunately calling end() will not close the socket if client refuses to close it. - // Hence calling destroy after a short while. One second should be more than enough - // to send out this small amount data. - setTimeout(() => { - socket.destroy(); - }, 1000); - }); - } catch (err) { - this.log(null, `Unhandled error in sendResponse(), will be ignored: ${err.stack || err}`); - } - } - - /** - * Starts listening at a port specified in the constructor. - * @param callback Optional callback - * @return {(Promise|undefined)} - */ - listen(callback) { - const promise = new Promise((resolve, reject) => { - // Unfortunately server.listen() is not a normal function that fails on error, - // so we need this trickery - const onError = (err) => { - this.log(null, `Listen failed: ${err}`); - removeListeners(); - reject(err); - }; - const onListening = () => { - this.port = this.server.address().port; - this.log(null, 'Listening...'); - removeListeners(); - resolve(); - }; - const removeListeners = () => { - this.server.removeListener('error', onError); - this.server.removeListener('listening', onListening); - }; - - this.server.on('error', onError); - this.server.on('listening', onListening); - this.server.listen(this.port); - }); - - return nodeify(promise, callback); - } - - /** - * Gets array of IDs of all active connections. - * @returns {*} - */ - getConnectionIds() { - return _.keys(this.handlers); - } - - /** - * Gets data transfer statistics of a specific proxy connection. - * @param {Number} connectionId ID of the connection handler. - * It is passed to `prepareRequestFunction` function. - * @return {Object} An object with statistics { srcTxBytes, srcRxBytes, trgTxBytes, trgRxBytes }, - * or null if connection does not exist or has been closed. - */ - getConnectionStats(connectionId) { - const handler = this.handlers && this.handlers[connectionId]; - if (!handler) return undefined; - - return handler.getStats(); - } - - /** - * Closes the proxy server. - * @param [closeConnections] If true, then all the pending connections from clients - * to targets and upstream proxies will be forcibly aborted. - * @param callback - */ - close(closeConnections, callback) { - if (typeof (closeConnections) === 'function') { - callback = closeConnections; - closeConnections = false; - } - - if (closeConnections) { - this.log(null, 'Closing pending handlers'); - let count = 0; - _.each(this.handlers, (handler) => { - count++; - handler.close(); - }); - this.log(null, `Destroyed ${count} pending handlers`); - } - - if (this.server) { - const { server } = this; - this.server = null; - const promise = util.promisify(server.close).bind(server)(); - return nodeify(promise, callback); - } - } -} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 00000000..918eb886 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,761 @@ +/* eslint-disable no-use-before-define */ +import { Buffer } from 'node:buffer'; +import type dns from 'node:dns'; +import { EventEmitter } from 'node:events'; +import http from 'node:http'; +import https from 'node:https'; +import type net from 'node:net'; +import { URL } from 'node:url'; +import util from 'node:util'; + +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:']; + +// TODO: +// - Implement this requirement from rfc7230 +// "A proxy MUST forward unrecognized header fields unless the field-name +// is listed in the Connection header field (Section 6.1) or the proxy +// is specifically configured to block, or otherwise transform, such +// fields. Other recipients SHOULD ignore unrecognized header fields. +// These requirements allow HTTP's functionality to be enhanced without +// requiring prior update of deployed intermediaries." + +const DEFAULT_AUTH_REALM = 'ProxyChain'; +const DEFAULT_PROXY_SERVER_PORT = 8000; + +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 satisfies https.ServerOptions; + +/** + * Connection statistics for bandwidth tracking. + */ +export type ConnectionStats = { + // Bytes sent by proxy to client. + srcTxBytes: number; + // Bytes received by proxy from client. + srcRxBytes: number; + // Bytes sent by proxy to target. + trgTxBytes: number | null; + // Bytes received by proxy from target. + trgRxBytes: number | null; +}; + +type HandlerOpts = { + server: Server; + id: number; + srcRequest: http.IncomingMessage; + srcResponse: http.ServerResponse | null; + srcHead: Buffer | null; + trgParsed: URL | null; + upstreamProxyUrlParsed: URL | null; + ignoreUpstreamProxyCertificate: boolean; + isHttp: boolean; + customResponseFunction?: CustomResponseOpts['customResponseFunction'] | null; + customConnectServer?: http.Server | null; + localAddress?: string; + ipFamily?: number; + dnsLookup?: typeof dns['lookup']; + customTag?: unknown; + httpAgent?: http.Agent; + httpsAgent?: https.Agent; +}; + +export type PrepareRequestFunctionOpts = { + connectionId: number; + request: http.IncomingMessage; + username: string; + password: string; + hostname: string; + port: number; + isHttp: boolean; +}; + +export type PrepareRequestFunctionResult = { + customResponseFunction?: CustomResponseOpts['customResponseFunction']; + customConnectServer?: http.Server | null; + requestAuthentication?: boolean; + failMsg?: string; + upstreamProxyUrl?: string | null; + ignoreUpstreamProxyCertificate?: boolean; + localAddress?: string; + ipFamily?: number; + dnsLookup?: typeof dns['lookup']; + customTag?: unknown; + httpAgent?: http.Agent; + httpsAgent?: https.Agent; +}; + +type Promisable = T | Promise; +export type PrepareRequestFunction = (opts: PrepareRequestFunctionOpts) => Promisable; + +type ServerOptionsBase = { + port?: number; + host?: string; + prepareRequestFunction?: PrepareRequestFunction; + verbose?: boolean; + authRealm?: unknown; +}; + +export type HttpServerOptions = ServerOptionsBase & { + serverType?: 'http'; +}; + +export type HttpsServerOptions = ServerOptionsBase & { + serverType: 'https'; + httpsOptions: https.ServerOptions; +}; + +export type ServerOptions = HttpServerOptions | HttpsServerOptions; + +/** + * Represents the proxy server. + * It emits the 'requestFailed' event on unexpected request errors, with the following parameter `{ error, request }`. + * It emits the 'connectionClosed' event when connection to proxy server is closed, with parameter `{ connectionId, stats }`. + * It emits the 'tlsError' event on TLS handshake failures (HTTPS servers only), with parameter `{ error, socket }`. + * with parameter `{ connectionId, reason, hasParent, parentType }`. + */ +export class Server extends EventEmitter { + port: number; + + host?: string; + + prepareRequestFunction?: PrepareRequestFunction; + + authRealm: unknown; + + verbose: boolean; + + server: http.Server | https.Server; + + serverType: 'http' | 'https'; + + lastHandlerId: number; + + stats: { httpRequestCount: number; connectRequestCount: number; }; + + connections: Map; + + /** + * Initializes a new instance of Server class. + * @param options + * @param [options.port] Port where the server will listen. By default 8000. + * @param [options.serverType] Type of server to create: 'http' or 'https'. By default 'http'. + * @param [options.httpsOptions] HTTPS server options (required when serverType is 'https'). + * Accepts standard Node.js https.ServerOptions including key, cert, ca, passphrase, etc. + * @param [options.prepareRequestFunction] Custom function to authenticate proxy requests, + * provide URL to upstream proxy or potentially provide a function that generates a custom response to HTTP requests. + * It accepts a single parameter which is an object: + * ``` + * { + * connectionId: symbol, + * request: http.IncomingMessage, + * username: string, + * password: string, + * hostname: string, + * port: number, + * isHttp: boolean, + * } + * ``` + * and returns an object (or promise resolving to the object) with following form: + * ``` + * { + * requestAuthentication: boolean, + * upstreamProxyUrl: string, + * customResponseFunction: Function, + * } + * ``` + * If `upstreamProxyUrl` is a falsy value, no upstream proxy is used. + * If `prepareRequestFunction` is not set, the proxy server will not require any authentication + * and will not use any upstream proxy. + * If `customResponseFunction` is set, it will be called to generate a custom response to the HTTP request. + * It should not be used together with `upstreamProxyUrl`. + * @param [options.authRealm] Realm used in the Proxy-Authenticate header and also in the 'Server' HTTP header. By default it's `ProxyChain`. + * @param [options.verbose] If true, the server will output logs + */ + constructor(options: ServerOptions = {}) { + super(); + + if (options.port === undefined || options.port === null) { + this.port = DEFAULT_PROXY_SERVER_PORT; + } else { + this.port = options.port; + } + + this.host = options.host; + this.prepareRequestFunction = options.prepareRequestFunction; + this.authRealm = options.authRealm || DEFAULT_AUTH_REALM; + this.verbose = !!options.verbose; + + // Keep legacy behavior (http) as default behavior. + this.serverType = options.serverType === 'https' ? 'https' : 'http'; + + if (options.serverType === 'https') { + if (!options.httpsOptions) { + throw new Error('httpsOptions is required when serverType is "https"'); + } + + // Apply secure TLS defaults (user options can override). + const effectiveOptions: https.ServerOptions = { + ...HTTPS_DEFAULT_OPTIONS, + honorCipherOrder: true, + ...options.httpsOptions, + }; + + this.server = https.createServer(effectiveOptions); + } else { + this.server = http.createServer(); + } + + // Attach common event handlers (same for both HTTP and HTTPS). + this.server.on('clientError', this.onClientError.bind(this)); + this.server.on('request', this.onRequest.bind(this)); + this.server.on('connect', this.onConnect.bind(this)); + + // Attach connection tracking based on server type. + // Only listen to one connection event to avoid double registration. + if (this.serverType === 'https') { + // For HTTPS: Track only post-TLS-handshake sockets (secureConnection). + // This ensures we track the TLS-wrapped socket with correct bytesRead/bytesWritten. + this.server.on('secureConnection', this.onConnection.bind(this)); + // Handle TLS handshake errors to prevent server crashes. + this.server.on('tlsClientError', this.onTLSClientError.bind(this)); + } else { + // For HTTP: Track raw TCP sockets (connection). + this.server.on('connection', this.onConnection.bind(this)); + } + + this.lastHandlerId = 0; + this.stats = { + httpRequestCount: 0, + connectRequestCount: 0, + }; + + this.connections = new Map(); + } + + /** + * Handles TLS handshake errors for HTTPS servers. + * Without this handler, unhandled TLS errors can crash the server. + * Common errors: ECONNRESET, ERR_SSL_SSLV3_ALERT_CERTIFICATE_UNKNOWN, + * ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION, ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE + */ + onTLSClientError(err: NodeJS.ErrnoException, tlsSocket: TLSSocket): void { + const connectionId = (tlsSocket as TLSSocket).proxyChainId; + this.log(connectionId, `TLS handshake failed: ${err.message}`); + + // Emit event in first place before any return statement. + this.emit('tlsError', { error: err, socket: tlsSocket }); + + // If connection already reset or socket not writable, nothing more to do. + if (err.code === 'ECONNRESET' || !tlsSocket.writable) { + return; + } + + // TLS handshake failed before HTTP, cannot send HTTP response. + // Destroy the socket to clean up. + tlsSocket.destroy(err); + } + + log(connectionId: unknown, str: string): void { + if (this.verbose) { + const logPrefix = connectionId != null ? `${String(connectionId)} | ` : ''; + // eslint-disable-next-line no-console + console.log(`ProxyServer[${this.port}]: ${logPrefix}${str}`); + } + } + + onClientError(err: NodeJS.ErrnoException, socket: Socket): void { + this.log(socket.proxyChainId, `onClientError: ${err}`); + + // https://nodejs.org/api/http.html#http_event_clienterror + if (err.code === 'ECONNRESET' || !socket.writable) { + return; + } + + this.sendSocketResponse(socket, 400, {}, 'Invalid request'); + } + + /** + * Assigns a unique ID to the socket and keeps the register up to date. + * Needed for abrupt close of the server. + */ + registerConnection(socket: Socket): void { + const unique = this.lastHandlerId++; + + socket.proxyChainId = unique; + this.connections.set(unique, socket); + + socket.on('close', () => { + this.emit('connectionClosed', { + connectionId: unique, + stats: this.getConnectionStats(unique), + }); + + this.connections.delete(unique); + }); + // We have to manually destroy the socket if it timeouts. + // This will prevent connections from leaking and close them properly. + socket.on('timeout', () => { + socket.destroy(); + }); + } + + /** + * Handles incoming sockets, useful for error handling + */ + onConnection(socket: Socket): void { + // https://github.com/nodejs/node/issues/23858 + if (!socket.remoteAddress) { + socket.destroy(); + return; + } + + this.registerConnection(socket); + + // We need to consume socket errors, because the handlers are attached asynchronously. + // See https://github.com/apify/proxy-chain/issues/53 + socket.on('error', (err) => { + // Handle errors only if there's no other handler + if (socket.listenerCount('error') === 1) { + this.log(socket.proxyChainId, `Source socket emitted error: ${err.stack || err}`); + } + }); + } + + /** + * Converts known errors to be instance of RequestError. + */ + normalizeHandlerError(error: NodeJS.ErrnoException): NodeJS.ErrnoException { + if (error.message === 'Username contains an invalid colon') { + return new RequestError('Invalid colon in username in upstream proxy credentials', badGatewayStatusCodes.AUTH_FAILED); + } + + if (error.message === '407 Proxy Authentication Required') { + return new RequestError('Invalid upstream proxy credentials', badGatewayStatusCodes.AUTH_FAILED); + } + + return error; + } + + /** + * Handles normal HTTP request by forwarding it to target host or the upstream proxy. + */ + async onRequest(request: http.IncomingMessage, response: http.ServerResponse): Promise { + try { + const handlerOpts = await this.prepareRequestHandling(request); + handlerOpts.srcResponse = response; + + const { proxyChainId } = request.socket as Socket; + + if (handlerOpts.customResponseFunction) { + this.log(proxyChainId, 'Using handleCustomResponse()'); + await handleCustomResponse(request, response, handlerOpts as CustomResponseOpts); + return; + } + + if (handlerOpts.upstreamProxyUrlParsed && SOCKS_PROTOCOLS.includes(handlerOpts.upstreamProxyUrlParsed.protocol)) { + this.log(proxyChainId, 'Using forwardSocks()'); + await forwardSocks(request, response, handlerOpts as ForwardOpts); + return; + } + + this.log(proxyChainId, 'Using forward()'); + await forward(request, response, handlerOpts as ForwardOpts); + } catch (error) { + this.failRequest(request, this.normalizeHandlerError(error as NodeJS.ErrnoException)); + } + } + + /** + * Handles HTTP CONNECT request by setting up a tunnel either to target host or to the upstream proxy. + * @param request + * @param socket + * @param head The first packet of the tunneling stream (may be empty) + */ + async onConnect(request: http.IncomingMessage, socket: Socket, head: Buffer): Promise { + try { + const handlerOpts = await this.prepareRequestHandling(request); + handlerOpts.srcHead = head; + + const data = { request, sourceSocket: socket, head, handlerOpts: handlerOpts as ChainOpts, server: this, isPlain: false }; + + if (handlerOpts.customConnectServer) { + socket.unshift(head); // See chain.ts for why we do this + await customConnect(socket, handlerOpts.customConnectServer); + return; + } + + if (handlerOpts.upstreamProxyUrlParsed) { + if (SOCKS_PROTOCOLS.includes(handlerOpts.upstreamProxyUrlParsed.protocol)) { + this.log(socket.proxyChainId, `Using chainSocks() => ${request.url}`); + await chainSocks(data); + return; + } + this.log(socket.proxyChainId, `Using chain() => ${request.url}`); + chain(data); + return; + } + + this.log(socket.proxyChainId, `Using direct() => ${request.url}`); + direct(data); + } catch (error) { + this.failRequest(request, this.normalizeHandlerError(error as NodeJS.ErrnoException)); + } + } + + /** + * Prepares handler options from a request. + * @see {prepareRequestHandling} + */ + getHandlerOpts(request: http.IncomingMessage): HandlerOpts { + const handlerOpts: HandlerOpts = { + server: this, + id: (request.socket as Socket).proxyChainId!, + srcRequest: request, + srcHead: null, + trgParsed: null, + upstreamProxyUrlParsed: null, + ignoreUpstreamProxyCertificate: false, + isHttp: false, + srcResponse: null, + customResponseFunction: null, + customConnectServer: null, + }; + + this.log((request.socket as Socket).proxyChainId, `!!! Handling ${request.method} ${request.url} HTTP/${request.httpVersion}`); + + if (request.method === 'CONNECT') { + // CONNECT server.example.com:80 HTTP/1.1 + try { + handlerOpts.trgParsed = new URL(`connect://${request.url}`); + } catch { + throw new RequestError(`Target "${request.url}" could not be parsed`, 400); + } + + if (!handlerOpts.trgParsed.hostname || !handlerOpts.trgParsed.port) { + throw new RequestError(`Target "${request.url}" could not be parsed`, 400); + } + + this.stats.connectRequestCount++; + } else { + // The request should look like: + // GET http://server.example.com:80/some-path HTTP/1.1 + // Note that RFC 7230 says: + // "When making a request to a proxy, other than a CONNECT or server-wide + // OPTIONS request (as detailed below), a client MUST send the target + // URI in absolute-form as the request-target" + + let parsed; + try { + parsed = new URL(request.url!); + } catch { + // If URL is invalid, throw HTTP 400 error + throw new RequestError(`Target "${request.url}" could not be parsed`, 400); + } + + // Only HTTP is supported, other protocols such as HTTP or FTP must use the CONNECT method + if (parsed.protocol !== 'http:') { + throw new RequestError(`Only HTTP protocol is supported (was ${parsed.protocol})`, 400); + } + + handlerOpts.trgParsed = parsed; + handlerOpts.isHttp = true; + + this.stats.httpRequestCount++; + } + + return handlerOpts; + } + + /** + * Calls `this.prepareRequestFunction` with normalized options. + * @param request + * @param handlerOpts + */ + async callPrepareRequestFunction(request: http.IncomingMessage, handlerOpts: HandlerOpts): Promise { + if (this.prepareRequestFunction) { + const funcOpts: PrepareRequestFunctionOpts = { + connectionId: (request.socket as Socket).proxyChainId!, + request, + username: '', + password: '', + hostname: handlerOpts.trgParsed!.hostname, + port: normalizeUrlPort(handlerOpts.trgParsed!), + isHttp: handlerOpts.isHttp, + }; + + // Authenticate the request using a user function (if provided) + const proxyAuth = request.headers['proxy-authorization']; + if (proxyAuth) { + const auth = parseAuthorizationHeader(proxyAuth); + + if (!auth) { + throw new RequestError('Invalid "Proxy-Authorization" header', 400); + } + + // https://datatracker.ietf.org/doc/html/rfc7617#page-3 + // Note that both scheme and parameter names are matched case- + // insensitively. + if (auth.type.toLowerCase() !== 'basic') { + throw new RequestError('The "Proxy-Authorization" header must have the "Basic" type.', 400); + } + + funcOpts.username = auth.username!; + funcOpts.password = auth.password!; + } + + const result = await this.prepareRequestFunction(funcOpts); + return result ?? {}; + } + + return {}; + } + + /** + * Authenticates a new request and determines upstream proxy URL using the user function. + * Returns a promise resolving to an object that can be used to run a handler. + * @param request + */ + async prepareRequestHandling(request: http.IncomingMessage): Promise { + const handlerOpts = this.getHandlerOpts(request); + const funcResult = await this.callPrepareRequestFunction(request, handlerOpts); + + handlerOpts.localAddress = funcResult.localAddress; + handlerOpts.ipFamily = funcResult.ipFamily; + handlerOpts.dnsLookup = funcResult.dnsLookup; + handlerOpts.customConnectServer = funcResult.customConnectServer; + handlerOpts.customTag = funcResult.customTag; + handlerOpts.httpAgent = funcResult.httpAgent; + handlerOpts.httpsAgent = funcResult.httpsAgent; + + // If not authenticated, request client to authenticate + if (funcResult.requestAuthentication) { + throw new RequestError(funcResult.failMsg || 'Proxy credentials required.', 407); + } + + if (funcResult.upstreamProxyUrl) { + try { + handlerOpts.upstreamProxyUrlParsed = new URL(funcResult.upstreamProxyUrl); + } catch (error) { + throw new Error(`Invalid "upstreamProxyUrl" provided: ${error} (was "${funcResult.upstreamProxyUrl}"`); + } + + if (!['http:', 'https:', ...SOCKS_PROTOCOLS].includes(handlerOpts.upstreamProxyUrlParsed.protocol)) { + throw new Error(`Invalid "upstreamProxyUrl" provided: URL must have one of the following protocols: "http", "https", ${SOCKS_PROTOCOLS.map((p) => `"${p.replace(':', '')}"`).join(', ')} (was "${funcResult.upstreamProxyUrl}")`); + } + } + + if (funcResult.ignoreUpstreamProxyCertificate !== undefined) { + handlerOpts.ignoreUpstreamProxyCertificate = funcResult.ignoreUpstreamProxyCertificate; + } + + const { proxyChainId } = request.socket as Socket; + + if (funcResult.customResponseFunction) { + this.log(proxyChainId, 'Using custom response function'); + + handlerOpts.customResponseFunction = funcResult.customResponseFunction; + + if (!handlerOpts.isHttp) { + throw new Error('The "customResponseFunction" option can only be used for HTTP requests.'); + } + + if (typeof (handlerOpts.customResponseFunction) !== 'function') { + throw new Error('The "customResponseFunction" option must be a function.'); + } + } + + if (handlerOpts.upstreamProxyUrlParsed) { + this.log(proxyChainId, `Using upstream proxy ${redactUrl(handlerOpts.upstreamProxyUrlParsed)}`); + } + + return handlerOpts; + } + + /** + * Sends a HTTP error response to the client. + * @param request + * @param error + */ + failRequest(request: http.IncomingMessage, error: NodeJS.ErrnoException): void { + const { proxyChainId } = request.socket as Socket; + + if (error.name === 'RequestError') { + const typedError = error as RequestError; + + this.log(proxyChainId, `Request failed (status ${typedError.statusCode}): ${error.message}`); + this.sendSocketResponse(request.socket, typedError.statusCode, typedError.headers, error.message); + } else { + this.log(proxyChainId, `Request failed with error: ${error.stack || error}`); + this.sendSocketResponse(request.socket, 500, {}, 'Internal error in proxy server'); + this.emit('requestFailed', { error, request }); + } + + this.log(proxyChainId, 'Closing because request failed with error'); + } + + /** + * Sends a simple HTTP response to the client and forcibly closes the connection. + * This invalidates the ServerResponse instance (if present). + * We don't know the state of the response anyway. + * Writing directly to the socket seems to be the easiest solution. + * @param socket + * @param statusCode + * @param headers + * @param message + */ + sendSocketResponse(socket: Socket, statusCode = 500, caseSensitiveHeaders = {}, message = ''): void { + try { + const headers = Object.fromEntries( + Object.entries(caseSensitiveHeaders).map( + ([name, value]) => [name.toLowerCase(), value], + ), + ); + + headers.connection = 'close'; + headers.date = (new Date()).toUTCString(); + headers['content-length'] = String(Buffer.byteLength(message)); + + headers.server = headers.server || this.authRealm; + headers['content-type'] = headers['content-type'] || 'text/plain; charset=utf-8'; + + if (statusCode === 407 && !headers['proxy-authenticate']) { + headers['proxy-authenticate'] = `Basic realm="${this.authRealm}"`; + } + + let msg = `HTTP/1.1 ${statusCode} ${http.STATUS_CODES[statusCode] || 'Unknown Status Code'}\r\n`; + for (const [key, value] of Object.entries(headers)) { + msg += `${key}: ${value}\r\n`; + } + msg += `\r\n${message}`; + + // Unfortunately it's not possible to send RST in Node.js yet. + // See https://github.com/nodejs/node/issues/27428 + socket.setTimeout(1000, () => { + socket.destroy(); + }); + + // This sends FIN, meaning we still can receive data. + socket.end(msg); + } catch (err) { + this.log(socket.proxyChainId, `Unhandled error in sendResponse(), will be ignored: ${(err as Error).stack || err}`); + } + } + + /** + * Starts listening at a port specified in the constructor. + */ + async listen(): Promise { + return new Promise((resolve, reject) => { + const onError = (error: NodeJS.ErrnoException) => { + this.log(null, `Listen error: ${error}`); + removeListeners(); + reject(error); + }; + const onListening = () => { + this.port = (this.server.address() as net.AddressInfo).port; + this.log(null, 'Listening...'); + removeListeners(); + resolve(); + }; + const removeListeners = () => { + this.server.removeListener('error', onError); + this.server.removeListener('listening', onListening); + }; + + this.server.on('error', onError); + this.server.on('listening', onListening); + this.server.listen(this.port, this.host); + }); + } + + /** + * Gets array of IDs of all active connections. + */ + getConnectionIds(): number[] { + return [...this.connections.keys()]; + } + + /** + * Gets data transfer statistics of a specific proxy connection. + */ + getConnectionStats(connectionId: number): ConnectionStats | undefined { + const socket = this.connections.get(connectionId); + if (!socket) return undefined; + + const targetStats = getTargetStats(socket); + + const result = { + srcTxBytes: socket.bytesWritten, + srcRxBytes: socket.bytesRead, + trgTxBytes: targetStats.bytesWritten, + trgRxBytes: targetStats.bytesRead, + }; + + return result; + } + + /** + * Forcibly close a specific pending proxy connection. + */ + closeConnection(connectionId: number): void { + this.log(null, 'Closing pending socket'); + + const socket = this.connections.get(connectionId); + if (!socket) return; + + socket.destroy(); + + this.log(null, `Destroyed pending socket`); + } + + /** + * Forcibly closes pending proxy connections. + */ + closeConnections(): void { + this.log(null, 'Closing pending sockets'); + + for (const socket of this.connections.values()) { + socket.destroy(); + } + + this.log(null, `Destroyed ${this.connections.size} pending sockets`); + } + + /** + * Closes the proxy server. + * @param closeConnections If true, pending proxy connections are forcibly closed. + */ + async close(closeConnections = false): Promise { + if (closeConnections) { + this.closeConnections(); + } + + if (this.server) { + const { server } = this; + // @ts-expect-error Let's make sure we can't access the server anymore. + this.server = null; + await util.promisify(server.close).bind(server)(); + } + } +} diff --git a/src/socket.ts b/src/socket.ts new file mode 100644 index 00000000..4b139470 --- /dev/null +++ b/src/socket.ts @@ -0,0 +1,7 @@ +import type net from 'node:net'; +import type tls from 'node:tls'; + +type AdditionalProps = { proxyChainId?: number }; + +export type Socket = net.Socket & AdditionalProps; +export type TLSSocket = tls.TLSSocket & AdditionalProps; diff --git a/src/statuses.ts b/src/statuses.ts new file mode 100644 index 00000000..ecb22eb0 --- /dev/null +++ b/src/statuses.ts @@ -0,0 +1,82 @@ +import { STATUS_CODES } from 'node:http'; + +type HttpStatusCode = number; + +export const badGatewayStatusCodes = { + /** + * Upstream has timed out. + */ + TIMEOUT: 504, + /** + * Upstream responded with non-200 status code. + */ + NON_200: 590, + /** + * Upstream respondend with status code different than 100-999. + */ + STATUS_CODE_OUT_OF_RANGE: 592, + /** + * DNS lookup failed - EAI_NODATA or EAI_NONAME. + */ + NOT_FOUND: 593, + /** + * Upstream refused connection. + */ + CONNECTION_REFUSED: 594, + /** + * Connection reset due to loss of connection or timeout. + */ + CONNECTION_RESET: 595, + /** + * Trying to write on a closed socket. + */ + BROKEN_PIPE: 596, + /** + * Incorrect upstream credentials. + */ + AUTH_FAILED: 597, + /** + * Generic upstream error. + */ + GENERIC_ERROR: 599, +} as const; + +STATUS_CODES['590'] = 'Non Successful'; +STATUS_CODES['592'] = 'Status Code Out Of Range'; +STATUS_CODES['593'] = 'Not Found'; +STATUS_CODES['594'] = 'Connection Refused'; +STATUS_CODES['595'] = 'Connection Reset'; +STATUS_CODES['596'] = 'Broken Pipe'; +STATUS_CODES['597'] = 'Auth Failed'; +STATUS_CODES['599'] = 'Upstream Error'; + +export const createCustomStatusHttpResponse = (statusCode: number, statusMessage: string, message = '') => { + return [ + `HTTP/1.1 ${statusCode} ${statusMessage || STATUS_CODES[statusCode] || 'Unknown Status Code'}`, + 'Connection: close', + `Date: ${(new Date()).toUTCString()}`, + `Content-Length: ${Buffer.byteLength(message)}`, + ``, + message, + ].join('\r\n'); +}; + +// https://nodejs.org/api/errors.html#common-system-errors +export const errorCodeToStatusCode: {[errorCode: string]: HttpStatusCode | undefined} = { + ENOTFOUND: badGatewayStatusCodes.NOT_FOUND, + ECONNREFUSED: badGatewayStatusCodes.CONNECTION_REFUSED, + ECONNRESET: badGatewayStatusCodes.CONNECTION_RESET, + EPIPE: badGatewayStatusCodes.BROKEN_PIPE, + ETIMEDOUT: badGatewayStatusCodes.TIMEOUT, +} as const; + +export const socksErrorMessageToStatusCode = (socksErrorMessage: string): typeof badGatewayStatusCodes[keyof typeof badGatewayStatusCodes] => { + switch (socksErrorMessage) { + case 'Proxy connection timed out': + return badGatewayStatusCodes.TIMEOUT; + case 'Socks5 Authentication failed': + return badGatewayStatusCodes.AUTH_FAILED; + default: + return badGatewayStatusCodes.GENERIC_ERROR; + } +}; diff --git a/src/tcp_tunnel.js b/src/tcp_tunnel.js deleted file mode 100644 index c7b47855..00000000 --- a/src/tcp_tunnel.js +++ /dev/null @@ -1,228 +0,0 @@ -import http from 'http'; -import { maybeAddProxyAuthorizationHeader } from './tools'; - -/** - * Represents a connection from source client to an external proxy using HTTP CONNECT tunnel, allows TCP connection. - */ -export default class TcpTunnel { - constructor({ - srcSocket, trgParsed, upstreamProxyUrlParsed, log, - }) { - this.log = log; - - // Bind all event handlers to this instance - this.bindHandlersToThis([ - 'onSrcSocketClose', 'onSrcSocketEnd', 'onSrcSocketError', - 'onTrgSocket', 'onTrgSocketClose', 'onTrgSocketEnd', 'onTrgSocketError', - 'onTrgRequestConnect', 'onTrgRequestAbort', 'onTrgRequestError', - ]); - - if (!trgParsed.hostname) throw new Error('The "trgParsed.hostname" option is required'); - if (!trgParsed.port) throw new Error('The "trgParsed.port" option is required'); - - this.trgRequest = null; - this.trgSocket = null; - this.trgParsed = trgParsed; - this.trgParsed.port = this.trgParsed.port || DEFAULT_TARGET_PORT; - - this.srcSocket = srcSocket; - this.srcSocket.on('close', this.onSrcSocketClose); - this.srcSocket.on('end', this.onSrcSocketEnd); - this.srcSocket.on('error', this.onSrcSocketError); - - this.upstreamProxyUrlParsed = upstreamProxyUrlParsed; - - this.isClosed = false; - } - - bindHandlersToThis(handlerNames) { - handlerNames.forEach((evt) => { - this[evt] = this[evt].bind(this); - }); - } - - run() { - this.log('Connecting to upstream proxy...'); - - const options = { - method: 'CONNECT', - hostname: this.upstreamProxyUrlParsed.hostname, - port: this.upstreamProxyUrlParsed.port, - path: `${this.trgParsed.hostname}:${this.trgParsed.port}`, - headers: {}, - }; - - maybeAddProxyAuthorizationHeader(this.upstreamProxyUrlParsed, options.headers); - - this.trgRequest = http.request(options); - - this.trgRequest.on('connect', this.onTrgRequestConnect); - this.trgRequest.on('abort', this.onTrgRequestAbort); - this.trgRequest.on('socket', this.onTrgSocket); - this.trgRequest.on('error', this.onTrgRequestError); - - // Send the data - this.trgRequest.end(); - } - - // If the client closes the connection prematurely, - // then immediately destroy the upstream socket, there's nothing we can do with it - onSrcSocketClose() { - if (this.isClosed) return; - this.log('Source socket closed'); - this.close(); - } - - onSrcSocketEnd() { - if (this.isClosed) return; - this.log('Source socket ended'); - this.close(); - } - - onSrcSocketError(err) { - if (this.isClosed) return; - this.log(`Source socket failed: ${err.stack || err}`); - this.close(); - } - - onTrgSocket(socket) { - if (this.isClosed || this.trgSocket) return; - - this.log('Target socket assigned'); - - this.trgSocket = socket; - - socket.on('close', this.onTrgSocketClose); - socket.on('end', this.onTrgSocketEnd); - socket.on('error', this.onTrgSocketError); - } - - // Once target socket closes, we need to give time - // to source socket to receive pending data, so we only call end() after a little while. - // One second should be about enough. - onTrgSocketClose() { - if (this.isClosed) return; - this.log('Target socket closed'); - setTimeout(() => { - if (this.srcSocket) this.srcSocket.end(); - }, 1000); - } - - // Same as onTrgSocketClose() above - onTrgSocketEnd() { - if (this.isClosed) return; - this.log('Target socket ended'); - setTimeout(() => { - if (this.srcSocket) this.srcSocket.end(); - }, 1000); - } - - onTrgSocketError(err) { - if (this.isClosed) return; - this.log(`Target socket failed: ${err.stack || err}`); - this.fail(err); - } - - onTrgRequestConnect(response, socket) { - if (this.isClosed) return; - this.log('Connected to upstream proxy'); - - // Attempt to fix https://github.com/apify/proxy-chain/issues/64, - // perhaps the 'connect' event might occur before 'socket' - if (!this.trgSocket) { - this.onTrgSocket(socket); - } - - if (this.checkUpstreamProxy407(response)) return; - - // Note that sockets could be closed anytime, causing this.close() to be called too in above statements - // See https://github.com/apify/proxy-chain/issues/64 - if (this.isClosed) return; - - // Setup bi-directional tunnel - this.trgSocket.pipe(this.srcSocket); - this.srcSocket.pipe(this.trgSocket); - - this.srcSocket.resume(); - } - - onTrgRequestAbort() { - if (this.isClosed) return; - this.log('Target aborted'); - this.close(); - } - - onTrgRequestError(err) { - if (this.isClosed) return; - this.log(`Target request failed: ${err.stack || err}`); - this.fail(err); - } - - /** - * Checks whether response from upstream proxy is 407 Proxy Authentication Required - * and if so, responds 502 Bad Gateway to client. - * @param response - * @return {boolean} - */ - checkUpstreamProxy407(response) { - if (this.upstreamProxyUrlParsed && response.statusCode === 407) { - this.fail('Invalid credentials provided for the upstream proxy.', 502); - return true; - } - return false; - } - - fail(err, statusCode) { - if (this.srcGotResponse) { - this.log('Source already received a response, just destroying the socket...'); - this.close(); - } else if (statusCode) { - // Manual error - this.log(`${err}, responding with custom status code ${statusCode} to client`); - } else if (err.code === 'ENOTFOUND' && !this.upstreamProxyUrlParsed) { - this.log('Target server not found, sending 404 to client'); - } else if (err.code === 'ENOTFOUND' && this.upstreamProxyUrlParsed) { - this.log('Upstream proxy not found, sending 502 to client'); - } else if (err.code === 'ECONNREFUSED') { - this.log('Upstream proxy refused connection, sending 502 to client'); - } else if (err.code === 'ETIMEDOUT') { - this.log('Connection timed out, sending 502 to client'); - } else if (err.code === 'ECONNRESET') { - this.log('Connection lost, sending 502 to client'); - } else if (err.code === 'EPIPE') { - this.log('Socket closed before write, sending 502 to client'); - } else { - this.log('Unknown error, sending 500 to client'); - } - } - - /** - * Detaches all listeners and destroys all sockets. - */ - close() { - if (!this.isClosed) { - this.log('Closing handler'); - this.isClosed = true; - - if (this.srcRequest) { - this.srcRequest.destroy(); - this.srcRequest = null; - } - - if (this.srcSocket) { - this.srcSocket.destroy(); - this.srcSocket = null; - } - - if (this.trgRequest) { - this.trgRequest.abort(); - this.trgRequest = null; - } - - if (this.trgSocket) { - this.trgSocket.destroy(); - this.trgSocket = null; - } - } - } -} diff --git a/src/tcp_tunnel_tools.js b/src/tcp_tunnel_tools.js deleted file mode 100644 index e6a3f042..00000000 --- a/src/tcp_tunnel_tools.js +++ /dev/null @@ -1,108 +0,0 @@ -import net from 'net'; -import TcpTunnel from './tcp_tunnel'; -import { parseUrl, nodeify } from './tools'; - -const runningServers = {}; - -export function createTunnel(proxyUrl, targetHost, providedOptions = {}, callback) { - const parsedProxyUrl = parseUrl(proxyUrl); - if (!parsedProxyUrl.hostname || !parsedProxyUrl.port) { - throw new Error(`The proxy URL must contain hostname and port (was "${proxyUrl}")`); - } - if (parsedProxyUrl.protocol !== 'http:') { - throw new Error(`The proxy URL must have the "http" protocol (was "${proxyUrl}")`); - } - if (/:/.test(parsedProxyUrl.username)) { - throw new Error('The proxy URL username cannot contain the colon (:) character according to RFC 7617.'); - } - - // TODO: More and better validations - yeah, make sure targetHost is really a hostname - const [trgHostname, trgPort] = (targetHost || '').split(':'); - if (!trgHostname || !trgPort) throw new Error('The target host needs to include both hostname and port.'); - - const options = { - verbose: false, - hostname: 'localhost', - port: null, - ...providedOptions, - }; - - const server = net.createServer(); - - const log = (...args) => { - if (options.verbose) console.log(...args); - }; - - server.on('connection', (srcSocket) => { - const port = server.address().port; - - runningServers[port].connections.push(srcSocket); - const remoteAddress = `${srcSocket.remoteAddress}:${srcSocket.remotePort}`; - log('new client connection from %s', remoteAddress); - - srcSocket.pause(); - - const tunnel = new TcpTunnel({ - srcSocket, - upstreamProxyUrlParsed: parsedProxyUrl, - trgParsed: { - hostname: trgHostname, - port: trgPort, - }, - log, - }); - - tunnel.run(); - - srcSocket.on('data', onConnData); - srcSocket.on('close', onConnClose); - srcSocket.on('error', onConnError); - - function onConnData(d) { - log('connection data from %s: %j', remoteAddress, d); - } - - function onConnClose() { - log('connection from %s closed', remoteAddress); - } - - function onConnError(err) { - log('Connection %s error: %s', remoteAddress, err.message); - } - }); - - const promise = new Promise((resolve) => { - // Let the system pick a random listening port - server.listen(0, (err) => { - if (err) return reject(err); - const address = server.address(); - log('server listening to ', address); - runningServers[address.port] = { server, connections: [] }; - resolve(`${options.hostname}:${address.port}`); - }); - }); - - return nodeify(promise, callback); -} - -export function closeTunnel(serverPath, closeConnections, callback) { - const [hostname, port] = serverPath.split(':'); - if (!hostname) throw new Error('serverPath must contain hostname'); - if (!port) throw new Error('serverPath must contain port'); - - const promise = new Promise((resolve) => { - if (!runningServers[port]) return resolve(false); - if (!closeConnections) return resolve(true); - runningServers[port].connections.forEach((connection) => connection.destroy()); - resolve(true); - }) - .then((serverExists) => new Promise((resolve) => { - if (!serverExists) return resolve(false); - runningServers[port].server.close(() => { - delete runningServers[port]; - resolve(true); - }); - })); - - return nodeify(promise, callback); -} diff --git a/src/tcp_tunnel_tools.ts b/src/tcp_tunnel_tools.ts new file mode 100644 index 00000000..1020b573 --- /dev/null +++ b/src/tcp_tunnel_tools.ts @@ -0,0 +1,123 @@ +import net from 'node:net'; +import { URL } from 'node:url'; + +import { chain } from './chain.js'; + +const runningServers: Record }> = {}; + +const getAddress = (server: net.Server) => { + const { address: host, port, family } = server.address() as net.AddressInfo; + + if (family === 'IPv6') { + return `[${host}]:${port}`; + } + + return `${host}:${port}`; +}; + +export async function createTunnel( + proxyUrl: string, + targetHost: string, + options?: { + verbose?: boolean; + ignoreProxyCertificate?: boolean; + }, +): Promise { + const parsedProxyUrl = new URL(proxyUrl); + if (!['http:', 'https:'].includes(parsedProxyUrl.protocol)) { + throw new Error(`The proxy URL must have the "http" or "https" protocol (was "${proxyUrl}")`); + } + + const url = new URL(`connect://${targetHost || ''}`); + + if (!url.hostname) { + throw new Error('Missing target hostname'); + } + + if (!url.port) { + throw new Error('Missing target port'); + } + + const verbose = options && options.verbose; + + const server: net.Server & { log?: (...args: unknown[]) => void } = net.createServer(); + + const log = (...args: unknown[]): void => { + // eslint-disable-next-line no-console + if (verbose) console.log(...args); + }; + + server.log = log; + + server.on('connection', (sourceSocket) => { + const remoteAddress = `${sourceSocket.remoteAddress}:${sourceSocket.remotePort}`; + + const { connections } = runningServers[getAddress(server)]; + + log(`new client connection from ${remoteAddress}`); + + sourceSocket.on('close', (hadError) => { + connections.delete(sourceSocket); + + log(`connection from ${remoteAddress} closed, hadError=${hadError}`); + }); + + connections.add(sourceSocket); + + chain({ + request: { url: targetHost }, + sourceSocket, + handlerOpts: { + upstreamProxyUrlParsed: parsedProxyUrl, + ignoreUpstreamProxyCertificate: options?.ignoreProxyCertificate ?? false, + }, + server: server as net.Server & { log: typeof log }, + isPlain: true, + }); + }); + + const promise = new Promise((resolve, reject) => { + server.once('error', reject); + + // Let the system pick a random listening port + server.listen(0, () => { + const address = getAddress(server); + + server.off('error', reject); + runningServers[address] = { server, connections: new Set() }; + + log('server listening to ', address); + + resolve(address); + }); + }); + + return promise; +} + +export async function closeTunnel( + serverPath: string, + closeConnections?: boolean, +): Promise { + const { hostname, port } = new URL(`tcp://${serverPath}`); + if (!hostname) throw new Error('serverPath must contain hostname'); + if (!port) throw new Error('serverPath must contain port'); + + const entry = runningServers[serverPath]; + if (!entry) return false; + + if (closeConnections) { + for (const connection of entry.connections) { + connection.destroy(); + } + } + + await new Promise((resolve) => { + entry.server.close(() => { + delete runningServers[serverPath]; + resolve(); + }); + }); + + return true; +} diff --git a/src/tools.js b/src/tools.js deleted file mode 100644 index c1e29208..00000000 --- a/src/tools.js +++ /dev/null @@ -1,272 +0,0 @@ -const HOST_HEADER_REGEX = /^((([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9]))(:([0-9]+))?$/; - -/** - * Parsed the 'Host' HTTP header and returns an object with { host: String, port: Number }. - * For example, for 'www.example.com:80' it returns { host: 'www.example.com', port: 80 }. - * If port is not present, the function - * If the header is invalid, returns null. - * @param hostHeader - * @return {*} - */ -export const parseHostHeader = (hostHeader) => { - const matches = HOST_HEADER_REGEX.exec(hostHeader || ''); - if (!matches) return null; - - const hostname = matches[1]; - if (hostname.length > 255) return null; - - let port = null; - if (matches[5]) { - port = parseInt(matches[6], 10); - if (!(port > 0 && port <= 65535)) return null; - } - - return { hostname, port }; -}; - -// As per HTTP specification, hop-by-hop headers should be consumed but the proxy, and not forwarded -const HOP_BY_HOP_HEADERS = [ - 'Connection', - 'Keep-Alive', - 'Proxy-Authenticate', - 'Proxy-Authorization', - 'TE', - 'Trailer', - 'Transfer-Encoding', - 'Upgrade', -]; - -const HOP_BY_HOP_HEADERS_REGEX = new RegExp(`^(${HOP_BY_HOP_HEADERS.join('|')})$`, 'i'); - -export const isHopByHopHeader = (header) => HOP_BY_HOP_HEADERS_REGEX.test(header); - -const TOKEN_REGEX = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/; - -/** - * Verifies that the given val is a valid HTTP token per the rules defined in RFC 7230 - * @see https://tools.ietf.org/html/rfc7230#section-3.2.6 - * @see https://github.com/nodejs/node/blob/8cf5ae07e9e80747c19e0fc04fad48423707f62c/lib/_http_common.js#L222 - */ -const isHttpToken = (val) => TOKEN_REGEX.test(val); - -const HEADER_CHAR_REGEX = /[^\t\x20-\x7e\x80-\xff]/; - -/** - * True if val contains an invalid field-vchar - * field-value = *( field-content / obs-fold ) - * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] - * field-vchar = VCHAR / obs-text - * @see https://github.com/nodejs/node/blob/8cf5ae07e9e80747c19e0fc04fad48423707f62c/lib/_http_common.js#L233 - */ -const isInvalidHeaderChar = (val) => HEADER_CHAR_REGEX.test(val); - -// This code is based on Node.js' validateHeader() function from _http_outgoing.js module -// (see https://github.com/nodejs/node/blob/189d29f39e6de9ccf10682bfd1341819b4a2291f/lib/_http_outgoing.js#L485) -export const isInvalidHeader = (name, value) => { - // NOTE: These are internal Node.js functions, they might stop working in the future! - return typeof name !== 'string' - || !name - || !isHttpToken(name) - || value === undefined - || isInvalidHeaderChar(value); -}; - -const bulletproofDecodeURIComponent = (encodedURIComponent) => { - try { - return decodeURIComponent(encodedURIComponent); - } catch (e) { - return encodedURIComponent; - } -}; - -// Ports returned by `parseUrl` when port is not explicitly specified. -// Values are based on node docs: https://nodejs.org/api/url.html#url_url_port -const STANDARD_PORTS_BY_PROTOCOL = { - 'ftp:': 21, - 'http:': 80, - 'https:': 443, - 'ws:': 80, - 'wss:': 443, -}; - -/** - * Parses a URL using Node.js' `new URL(url)` and adds the following features: - * - `port` is casted to number / null from string - * - `path` field is added (pathname + search) - * - both username and password is URI-decoded - * - `auth` field is added (username + ":" + password, or empty string) - * - * Note that compared to the old implementation using `url.parse()`, the new function: - * - is unable to distinguish empty password and missing password - * - password and username are empty string if not present (or empty) - * - we are able to parse IPv6 - * - * @param url - * @ignore - */ -export const parseUrl = (url) => { - // NOTE: In the past we used url.parse() here, but it can't handle IPv6 and other special URLs, - // so we moved to new URL() - const urlObj = new URL(url); - - const parsed = { - auth: urlObj.username || urlObj.password ? `${urlObj.username}:${urlObj.password}` : '', - hash: urlObj.hash, - host: urlObj.host, - hostname: urlObj.hostname, - href: urlObj.href, - origin: urlObj.origin, - // The username and password might not be correctly URI-encoded, try to make it work anyway - username: bulletproofDecodeURIComponent(urlObj.username), - password: bulletproofDecodeURIComponent(urlObj.password), - pathname: urlObj.pathname, - // Path was present on the original UrlObject, it's kept for backwards compatibility - path: `${urlObj.pathname}${urlObj.search}`, - // Port is turned into a number if available - port: urlObj.port ? parseInt(urlObj.port, 10) : null, - protocol: urlObj.protocol, - scheme: null, - search: urlObj.search, - searchParams: urlObj.searchParams, - }; - - // Add scheme field (as some other external tools rely on that) - if (parsed.protocol) { - const matches = /^([a-z0-9]+):$/i.exec(parsed.protocol); - if (matches && matches.length === 2) { - parsed.scheme = matches[1]; - } - } - - // Add default port based on protocol when no port is explicitly specified. - if (parsed.port === null) { - parsed.port = STANDARD_PORTS_BY_PROTOCOL[parsed.protocol] || null; - } - - return parsed; -}; - -/** - * Redacts password from a URL, so that it can be shown in logs, results etc. - * For example, converts URL such as - * 'https://username:password@www.example.com/path#hash' - * to 'https://username:@www.example.com/path#hash' - * @param url URL, it must contain at least protocol and hostname - * @param passwordReplacement The string that replaces password, by default it is '' - * @returns {string} - * @ignore - */ -export const redactUrl = (url, passwordReplacement) => { - return redactParsedUrl(parseUrl(url), passwordReplacement); -}; - -export const redactParsedUrl = (parsedUrl, passwordReplacement = '') => { - const p = parsedUrl; - let auth = null; - if (p.username) { - if (p.password) { - auth = `${p.username}:${passwordReplacement}`; - } else { - auth = `${p.username}`; - } - } - return `${p.protocol}//${auth || ''}${auth ? '@' : ''}${p.host}${p.path || ''}${p.hash || ''}`; -}; - -const PROXY_AUTH_HEADER_REGEX = /^([a-z0-9-]+) ([a-z0-9+/=]+)$/i; - -/** - * Parses the content of the Proxy-Authorization HTTP header. - * @param header - * @returns {*} Object with fields { type: String, username: String, password: String } - * or null if string parsing failed. Note that password and username might be empty strings. - */ -export const parseProxyAuthorizationHeader = (header) => { - const matches = PROXY_AUTH_HEADER_REGEX.exec(header); - if (!matches) return null; - - const auth = Buffer.from(matches[2], 'base64').toString(); - if (!auth) return null; - - const index = auth.indexOf(':'); - return { - type: matches[1], - username: index >= 0 ? auth.substr(0, index) : auth, - password: index >= 0 ? auth.substr(index + 1) : '', - }; -}; - -/** - * Works like Bash tee, but instead of passing output to file, - * passes output to log - * - * @param {String} name identifier - * @param {Boolean} initialOnly log only initial chunk of data - * @return {through} duplex stream (pipe) - -export const tee = (name, initialOnly = true) => { - console.log('tee'); - let maxChunks = 2; - const duplex = through((chunk) => { - if (maxChunks || !initialOnly) { - // let msg = chunk.toString(); - // msg += ''; - maxChunks--; - console.log(`pipe: ${JSON.stringify({ - context: name, - chunkHead: chunk.toString().slice(0, 100), - })}`); - } - duplex.queue(chunk); - }); - - return duplex; -}; -*/ - -export const addHeader = (headers, name, value) => { - if (headers[name] === undefined) { - headers[name] = value; - } else if (Array.isArray(headers[name])) { - headers[name].push(value); - } else { - headers[name] = [ - headers[name], - value, - ]; - } -}; - -export const PORT_SELECTION_CONFIG = { - FROM: 20000, - TO: 60000, - RETRY_COUNT: 10, -}; - -export const maybeAddProxyAuthorizationHeader = (parsedUrl, headers) => { - if (parsedUrl && (parsedUrl.username || parsedUrl.password)) { - // According to RFC 7617 (see https://tools.ietf.org/html/rfc7617#page-5): - // "Furthermore, a user-id containing a colon character is invalid, as - // the first colon in a user-pass string separates user-id and password - // from one another; text after the first colon is part of the password. - // User-ids containing colons cannot be encoded in user-pass strings." - // So to be correct and avoid strange errors later, we just throw an error - if (/:/.test(parsedUrl.username)) throw new Error('The proxy username cannot contain the colon (:) character according to RFC 7617.'); - const auth = `${parsedUrl.username || ''}:${parsedUrl.password || ''}`; - headers['Proxy-Authorization'] = `Basic ${Buffer.from(auth).toString('base64')}`; - } -}; - -// Replacement for Bluebird's Promise.nodeify() -export const nodeify = (promise, callback) => { - if (typeof callback !== 'function') return promise; - - const p = promise.then((result) => callback(null, result), callback); - - // Handle error from callback function - p.catch((e) => { - setTimeout(() => { throw e; }, 0); - }); - - return promise; -}; diff --git a/src/utils/count_target_bytes.ts b/src/utils/count_target_bytes.ts new file mode 100644 index 00000000..0ed783a1 --- /dev/null +++ b/src/utils/count_target_bytes.ts @@ -0,0 +1,75 @@ +import type net from 'node:net'; + +const targetBytesWritten = Symbol('targetBytesWritten'); +const targetBytesRead = Symbol('targetBytesRead'); +const targets = Symbol('targets'); +const calculateTargetStats = Symbol('calculateTargetStats'); + +type Stats = { bytesWritten: number | null, bytesRead: number | null }; + +/** + * Socket object extended with previous read and written bytes. + * Necessary due to target socket re-use. + */ +export type SocketWithPreviousStats = net.Socket & { previousBytesWritten?: number, previousBytesRead?: number }; + +interface Extras { + [targetBytesWritten]: number; + [targetBytesRead]: number; + [targets]: Set; + [calculateTargetStats]: () => Stats; +} + +export const countTargetBytes = ( + sourceSocket: net.Socket, + target: SocketWithPreviousStats, + registerCloseHandler?: (handler: () => void) => void, +): void => { + const source = sourceSocket as net.Socket & Extras; + + source[targetBytesWritten] = source[targetBytesWritten] || 0; + source[targetBytesRead] = source[targetBytesRead] || 0; + source[targets] = source[targets] || new Set(); + + source[targets].add(target); + + const closeHandler = () => { + source[targetBytesWritten] += (target.bytesWritten - (target.previousBytesWritten || 0)); + source[targetBytesRead] += (target.bytesRead - (target.previousBytesRead || 0)); + source[targets].delete(target); + }; + if (!registerCloseHandler) { + registerCloseHandler = (handler: () => void) => target.once('close', handler); + } + registerCloseHandler(closeHandler); + + if (!source[calculateTargetStats]) { + source[calculateTargetStats] = () => { + let bytesWritten = source[targetBytesWritten]; + let bytesRead = source[targetBytesRead]; + + for (const socket of source[targets]) { + bytesWritten += (socket.bytesWritten - (socket.previousBytesWritten || 0)); + bytesRead += (socket.bytesRead - (socket.previousBytesRead || 0)); + } + + return { + bytesWritten, + bytesRead, + }; + }; + } +}; + +export const getTargetStats = (rawSocket: net.Socket): Stats => { + const socket = rawSocket as net.Socket & Extras; + + if (socket[calculateTargetStats]) { + return socket[calculateTargetStats](); + } + + return { + bytesWritten: null, + bytesRead: null, + }; +}; diff --git a/src/utils/decode_uri_component_safe.ts b/src/utils/decode_uri_component_safe.ts new file mode 100644 index 00000000..b7734de0 --- /dev/null +++ b/src/utils/decode_uri_component_safe.ts @@ -0,0 +1,7 @@ +export const decodeURIComponentSafe = (encodedURIComponent: string): string => { + try { + return decodeURIComponent(encodedURIComponent); + } catch { + return encodedURIComponent; + } +}; diff --git a/src/utils/get_basic.ts b/src/utils/get_basic.ts new file mode 100644 index 00000000..d5e94dab --- /dev/null +++ b/src/utils/get_basic.ts @@ -0,0 +1,15 @@ +import type { URL } from 'node:url'; + +import { decodeURIComponentSafe } from './decode_uri_component_safe.js'; + +export const getBasicAuthorizationHeader = (url: URL): string => { + const username = decodeURIComponentSafe(url.username); + const password = decodeURIComponentSafe(url.password); + const auth = `${username}:${password}`; + + if (username.includes(':')) { + throw new Error('Username contains an invalid colon'); + } + + return `Basic ${Buffer.from(auth).toString('base64')}`; +}; diff --git a/src/utils/is_hop_by_hop_header.ts b/src/utils/is_hop_by_hop_header.ts new file mode 100644 index 00000000..a2b9ef08 --- /dev/null +++ b/src/utils/is_hop_by_hop_header.ts @@ -0,0 +1,13 @@ +// As per HTTP specification, hop-by-hop headers should be consumed but the proxy, and not forwarded +const hopByHopHeaders = [ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]; + +export const isHopByHopHeader = (header: string): boolean => hopByHopHeaders.includes(header.toLowerCase()); diff --git a/src/utils/normalize_url_port.ts b/src/utils/normalize_url_port.ts new file mode 100644 index 00000000..54d7bb8a --- /dev/null +++ b/src/utils/normalize_url_port.ts @@ -0,0 +1,22 @@ +import type { URL } from 'node:url'; + +// https://url.spec.whatwg.org/#default-port +const mapping = { + 'ftp:': 21, + 'http:': 80, + 'https:': 443, + 'ws:': 80, + 'wss:': 443, +}; + +export const normalizeUrlPort = (url: URL): number => { + if (url.port) { + return Number(url.port); + } + + if (url.protocol in mapping) { + return mapping[url.protocol as keyof typeof mapping]; + } + + throw new Error(`Unexpected protocol: ${url.protocol}`); +}; diff --git a/src/utils/parse_authorization_header.ts b/src/utils/parse_authorization_header.ts new file mode 100644 index 00000000..ab9c52bc --- /dev/null +++ b/src/utils/parse_authorization_header.ts @@ -0,0 +1,64 @@ +import { Buffer } from 'node:buffer'; + +const splitAt = (string: string, index: number) => { + return [ + index === -1 ? '' : string.substring(0, index), + index === -1 ? '' : string.substring(index + 1), + ]; +}; + +interface Authorization { + type: string; + data: string; + username?: string; + password?: string; +} + +export const parseAuthorizationHeader = (header: string): Authorization | null => { + if (header) { + header = header.trim(); + } + + if (!header) { + return null; + } + + const [type, data] = splitAt(header, header.indexOf(' ')); + + // https://datatracker.ietf.org/doc/html/rfc7617#page-3 + // Note that both scheme and parameter names are matched case- + // insensitively. + if (type.toLowerCase() !== 'basic') { + return { type, data }; + } + + const auth = Buffer.from(data, 'base64').toString(); + + // https://datatracker.ietf.org/doc/html/rfc7617#page-5 + // To receive authorization, the client + // + // 1. obtains the user-id and password from the user, + // + // 2. constructs the user-pass by concatenating the user-id, a single + // colon (":") character, and the password, + // + // 3. encodes the user-pass into an octet sequence (see below for a + // discussion of character encoding schemes), + // + // 4. and obtains the basic-credentials by encoding this octet sequence + // using Base64 ([RFC4648], Section 4) into a sequence of US-ASCII + // characters ([RFC0020]). + + // Note: + // If there's a colon : missing, we imply that the user-pass string is just a username. + // This is a non-spec behavior. At Apify there are clients that rely on this. + // If you want this behavior changed, please open an issue. + const [username, password] = auth.includes(':') ? splitAt(auth, auth.indexOf(':')) : [auth, '']; + + return { + type, + data, + username, + password, + }; +}; diff --git a/src/utils/redact_url.ts b/src/utils/redact_url.ts new file mode 100644 index 00000000..ca384717 --- /dev/null +++ b/src/utils/redact_url.ts @@ -0,0 +1,13 @@ +import { URL } from 'node:url'; + +export const redactUrl = (url: string | URL, passwordReplacement = ''): string => { + if (typeof url !== 'object') { + url = new URL(url); + } + + if (url.password) { + return url.href.replace(`:${url.password}`, `:${passwordReplacement}`); + } + + return url.href; +}; diff --git a/src/utils/valid_headers_only.ts b/src/utils/valid_headers_only.ts new file mode 100644 index 00000000..a5f6b4f6 --- /dev/null +++ b/src/utils/valid_headers_only.ts @@ -0,0 +1,40 @@ +import { validateHeaderName, validateHeaderValue } from 'node:http'; + +import { isHopByHopHeader } from './is_hop_by_hop_header.js'; + +/** + * @see https://nodejs.org/api/http.html#http_message_rawheaders + */ +export const validHeadersOnly = (rawHeaders: string[]): string[] => { + const result = []; + + let containsHost = false; + + for (let i = 0; i < rawHeaders.length; i += 2) { + const name = rawHeaders[i]; + const value = rawHeaders[i + 1]; + + try { + validateHeaderName(name); + validateHeaderValue(name, value); + } catch { + continue; + } + + if (isHopByHopHeader(name)) { + continue; + } + + if (name.toLowerCase() === 'host') { + if (containsHost) { + continue; + } + + containsHost = true; + } + + result.push(name, value); + } + + return result; +}; diff --git a/test/.eslintrc.json b/test/.eslintrc.json new file mode 100644 index 00000000..1e658388 --- /dev/null +++ b/test/.eslintrc.json @@ -0,0 +1,16 @@ +{ + "globals": { + "it": false, + "describe": false, + "xit": false, + "before": false, + "after": false, + "beforeAll": false, + "afterAll": false, + "beforeEach": false, + "afterEach": false, + "expect": false, + "test": false, + "jest": false + } +} diff --git a/test/Dockerfile b/test/Dockerfile index 09bcff1e..d3c1ea11 100644 --- a/test/Dockerfile +++ b/test/Dockerfile @@ -1,21 +1,18 @@ -# You can use this Dockerfile to run the tests on Linux, -# since the sockets behave a little differently there than on Mac -# -# Usage: -# > docker build . -# > docker run +ARG NODE_IMAGE=node:20.19.6-bookworm +FROM ${NODE_IMAGE} -FROM node:10 +RUN apt-get update && apt-get install -y --no-install-recommends chromium \ + && rm -rf /var/lib/apt/lists/* -COPY .. /home/node/ +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium -RUN npm --quiet set progress=false \ - && npm install --only=prod --no-optional \ - && echo "Installed NPM packages:" \ - && npm list || true \ - && echo "Node.js version:" \ - && node --version \ - && echo "NPM version:" \ - && npm --version +WORKDIR /home/node -CMD cd /home/node && npm test +COPY --chown=node:node package*.json ./ +RUN npm --quiet set progress=false && npm install --no-optional +COPY --chown=node:node . . + +USER node + +ENTRYPOINT [ "npm", "test", "--" ] diff --git a/test/README.md b/test/README.md index 198503bb..4b72a7c7 100644 --- a/test/README.md +++ b/test/README.md @@ -1,9 +1,112 @@ +# Tests -To run the tests, you need to add the following line to your `/etc/hosts`: +The test suite is split into two directories: +- `test/unit/` — pure unit tests over utility helpers (no network, no proxy + servers). Fast; runs in CI on every supported major Node.js version. +- `test/e2e/` — end-to-end tests that spin up real HTTP/HTTPS/SOCKS proxy + servers and target servers. Heavier; runs in CI on the latest Node.js + only. + +Shared helpers live in `test/utils/`. + +## Docker (recommended) + +Since Linux and macOS handle sockets differently, please run tests in a Docker container +to have a consistent Linux environment for running tests. + +1. Run all tests + + ```bash + npm run test:docker + ``` + +2. Run a specific test file + + ```bash + npm run test:docker test/e2e/server.js + ``` + +3. Run all `direct ipv6` test cases across all tests + + ```bash + npm run test:docker test/e2e/server.js -- --grep "direct ipv6" + ``` + +Note: for test in Docker no changes in `/etc/hosts` needed. + +## Local Machine + +### Prerequisites + +1. Node.js 20+ (see `.nvmrc` for exact version) +2. For MacOS with ARM CPUs install Rosetta (workaround for puppeteer) +3. Update `/etc/hosts` + + ```bash + # Used by proxy-chain NPM package tests + 127.0.0.1 localhost + 127.0.0.1 localhost-test + ``` + + The `localhost` entry is for avoiding dual-stack issues, e.g. when the test server listens at ::1 + (results of getaddrinfo have specified order) and the client attempts to connect to 127.0.0.1 . + + The `localhost-test` entry is a workaround to PhantomJS' behavior where it skips proxy servers for + localhost addresses. + +### Run tests + +1. Run all tests (unit + e2e) + + ```bash + npm test + ``` + +2. Run only unit tests + + ```bash + npm run test:unit + ``` + +3. Run only e2e tests + + ```bash + npm run test:e2e + ``` + +4. Run a specific test file + + ```bash + npm test test/e2e/anonymize_proxy.js + ``` + +### Run tests with Bun + +[Bun](https://bun.com) is supported as an alternative runtime. Install it from +https://bun.com, then run: + +```bash +# Unit tests (always green on Bun, gates every PR) +npm run test:bun + +# E2E tests — curated subset known to pass on Bun +npm run test:bun:e2e:compatible + +# E2E tests — entire suite (some tests rely on Node-only HTTP semantics +# such as HTTP/1.1 pipelining and stream.pipeline behaviour that current +# Bun releases don't fully emulate; expect failures) +npm run test:bun:e2e:full ``` -# Used by proxy-chain NPM package tests -127.0.0.1 localhost-test -``` -This is a workaround to PhantomJS' behavior where it skips proxy servers for localhost addresses. \ No newline at end of file +In CI, `bun_unit` and `bun_e2e` (in `compatible` mode) run on every PR. +The full Bun e2e suite is opt-in: trigger the **Check** workflow via +**Actions → Check → Run workflow** and pick `full` for the +`bun_e2e_mode` input. + +The `compatible` subset is intentionally narrow today — it only runs the +URL-validation tests in `test/e2e/tcp_tunnel.js` (via `--grep 'throws +error'`), which exercise `createTunnel`'s error paths without touching +the network. As individual networked tests are confirmed to pass on +Bun, widen the `test:bun:e2e:compatible` script in `package.json` (drop +the `--grep`, add files, or list specific test names). diff --git a/test/anonymize_proxy.js b/test/anonymize_proxy.js deleted file mode 100644 index a3e16bd3..00000000 --- a/test/anonymize_proxy.js +++ /dev/null @@ -1,404 +0,0 @@ -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('../build/index'); -const { findFreePort } = require('./tools'); - -let proxyServer; -let proxyPort; // eslint-disable-line no-unused-vars -let testServerPort; -const proxyAuth = { scheme: 'Basic', username: 'username', password: 'password' }; -let wasProxyCalled = false; // eslint-disable-line no-unused-vars - -const serverListen = (server, port) => new Promise((resolve, reject) => { - server.listen(port, (err) => { - if (err) return reject(err); - return resolve(port); - }); -}); - -// Setup local proxy server and web server for the tests -before(() => { - // Find free port for the proxy - let freePorts; - return portastic.find({ min: 50000, max: 50100 }) - .then((result) => { - freePorts = result; - return new Promise((resolve, reject) => { - const httpServer = http.createServer(); - - // Setup proxy authorization - httpServer.authenticate = function (req, fn) { - // parse the "Proxy-Authorization" header - const auth = req.headers['proxy-authorization']; - if (!auth) { - // optimization: don't invoke the child process if no - // "Proxy-Authorization" header was given - // console.log('not Proxy-Authorization'); - return fn(null, false); - } - const parsed = basicAuthParser(auth); - const isEqual = _.isEqual(parsed, proxyAuth); - // console.log('Parsed "Proxy-Authorization": parsed: %j expected: %j : %s', parsed, proxyAuth, isEqual); - if (isEqual) wasProxyCalled = true; - fn(null, isEqual); - }; - - httpServer.on('error', reject); - - proxyServer = proxy(httpServer); - proxyServer.listen(freePorts[0], () => { - proxyPort = proxyServer.address().port; - resolve(); - }); - }); - }) - .then(() => { - const app = express(); - - app.get('/', (req, res) => res.send('Hello World!')); - - testServerPort = freePorts[1]; - return new Promise((resolve, reject) => { - app.listen(testServerPort, (err) => { - if (err) reject(err); - resolve(); - }); - }); - }); -}); - -after(function () { - this.timeout(5 * 1000); - if (proxyServer) return util.promisify(proxyServer.close).bind(proxyServer)(); -}); - - -const requestPromised = (opts) => { - // console.log('requestPromised'); - // console.dir(opts); - return new Promise((resolve, reject) => { - request(opts, (error, response, body) => { - if (error) return reject(error); - if (response.statusCode !== 200) { - return reject(new Error(`Received invalid response code: ${response.statusCode}`)); - } - if (opts.expectBodyContainsText) expect(body).to.contain(opts.expectBodyContainsText); - resolve(); - }); - }); -}; - - -describe('utils.anonymizeProxy', function () { - // Need larger timeout for Travis CI - this.timeout(5 * 1000); - it('throws for invalid args', () => { - assert.throws(() => { anonymizeProxy(null); }, Error); - assert.throws(() => { anonymizeProxy(); }, Error); - assert.throws(() => { anonymizeProxy({}); }, Error); - - assert.throws(() => { closeAnonymizedProxy({}); }, Error); - assert.throws(() => { closeAnonymizedProxy(); }, Error); - assert.throws(() => { closeAnonymizedProxy(null); }, Error); - }); - - it('throws for unsupported proxy protocols', () => { - assert.throws(() => { anonymizeProxy('socks://whatever.com'); }, Error); - assert.throws(() => { anonymizeProxy('https://whatever.com'); }, Error); - assert.throws(() => { anonymizeProxy('socks5://whatever.com'); }, Error); - }); - - it('throws for invalid URLs', () => { - assert.throws(() => { anonymizeProxy('://whatever.com'); }, Error); - assert.throws(() => { anonymizeProxy('https://whatever.com'); }, Error); - assert.throws(() => { anonymizeProxy('socks5://whatever.com'); }, Error); - }); - - 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('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 = results[0]; - proxyUrl2 = results[1]; - 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; - 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(() => { - return closeAnonymizedProxy(proxyUrl1, true); - }) - .then((closed) => { - expect(closed).to.eql(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); - - // 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, closed) => { - if (err) return reject(err); - resolve(closed); - }); - }); - }) - .then((closed) => { - expect(closed).to.eql(false); - }); - }); - - it('handles many concurrent calls without port collision', () => { - const N = 20; - let proxyUrls; - - return Promise.resolve() - .then(() => { - const promises = []; - for (let i = 0; i < N; i++) { - promises.push(anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`)); - } - - return Promise.all(promises); - }) - .then((results) => { - const promises = []; - proxyUrls = results; - for (let i=0; i { - expect(wasProxyCalled).to.equal(true); - const promises = []; - - for (let i=0; i { - for (let i=0; i { - var proxy = http.createServer(); - proxy.on('connect', onconnect); - proxyPort = port; - return serverListen(proxy, proxyPort); - }) - .then(() => { - return anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`); - }) - .then((proxyUrl) => { - return requestPromised({ - uri: `https://${host}`, - proxy: proxyUrl, - }) - .catch(() => { - return Promise.resolve(); - }); - }) - .then(() => { - expect(onconnectArgs.headers.host).to.equal(host); - expect(onconnectArgs.url).to.equal(host); - }); - }); - - it('handles HTTP CONNECT callback properly', function () { - - this.timeout(50 * 1000); - - const host = `localhost:${testServerPort}`; - let proxyPort; - let rawHeadersRetrieved; - function onconnect(message, socket) { - socket.write("HTTP/1.1 200 OK\r\nfoo: bar\r\n\r\n"); - socket.end(); - socket.destroy(); - } - return findFreePort() - .then((port) => { - var proxy = http.createServer(); - proxy.on('connect', onconnect); - proxyPort = port; - return serverListen(proxy, proxyPort); - }) - .then(() => { - return anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`); - }) - .then((proxyUrl) => { - listenConnectAnonymizedProxy(proxyUrl, ({ response, socket, head }) => { - rawHeadersRetrieved = response.rawHeaders; - }); - return requestPromised({ - uri: `https://${host}`, - proxy: proxyUrl, - }) - .catch(() => { - return Promise.resolve(); - }); - }) - .then(() => { - expect(rawHeadersRetrieved).to.eql(['foo', 'bar']); - }); - }); - - - it('fails with invalid upstream proxy credentials', () => { - let anonymousProxyUrl; - return Promise.resolve() - .then(() => { - return anonymizeProxy(`http://username:bad-password@127.0.0.1:${proxyPort}`); - }) - .then((result) => { - anonymousProxyUrl = result; - expect(anonymousProxyUrl).to.not.contain(`${proxyPort}`); - wasProxyCalled = false; - return requestPromised({ - uri: 'http://whatever', - proxy: anonymousProxyUrl, - }); - }) - .then(() => { - assert.fail(); - }) - .catch((err) => { - expect(err.message).to.contains('Received invalid response code: 502'); // Gateway error - expect(wasProxyCalled).to.equal(false); - }) - .then(() => { - return closeAnonymizedProxy(anonymousProxyUrl, true); - }) - .then((closed) => { - expect(closed).to.eql(true); - }); - }); -}); diff --git a/test/anonymize_proxy_no_password.js b/test/anonymize_proxy_no_password.js deleted file mode 100644 index cf62b38f..00000000 --- a/test/anonymize_proxy_no_password.js +++ /dev/null @@ -1,207 +0,0 @@ -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('../build/index'); -const { PORT_SELECTION_CONFIG } = require('./tools'); - -const ORIG_PORT_SELECTION_CONFIG = { ...PORT_SELECTION_CONFIG }; - -let proxyServer; -let proxyPort; // eslint-disable-line no-unused-vars -let testServerPort; -const proxyAuth = { scheme: 'Basic', username: 'username', password: '' }; -let wasProxyCalled = false; // eslint-disable-line no-unused-vars - -// Setup local proxy server and web server for the tests -before(() => { - // Find free port for the proxy - let freePorts; - return portastic.find({ min: 50000, max: 50100 }) - .then((result) => { - freePorts = result; - return new Promise((resolve, reject) => { - const httpServer = http.createServer(); - - // Setup proxy authorization - httpServer.authenticate = function (req, fn) { - // parse the "Proxy-Authorization" header - const auth = req.headers['proxy-authorization']; - if (!auth) { - // optimization: don't invoke the child process if no - // "Proxy-Authorization" header was given - // console.log('not Proxy-Authorization'); - return fn(null, false); - } - const parsed = basicAuthParser(auth); - const isEqual = _.isEqual(parsed, proxyAuth); - // console.log('Parsed "Proxy-Authorization": parsed: %j expected: %j : %s', parsed, proxyAuth, isEqual); - if (isEqual) wasProxyCalled = true; - fn(null, isEqual); - }; - - httpServer.on('error', reject); - - proxyServer = proxy(httpServer); - proxyServer.listen(freePorts[0], () => { - proxyPort = proxyServer.address().port; - resolve(); - }); - }); - }) - .then(() => { - const app = express(); - - app.get('/', (req, res) => res.send('Hello World!')); - - testServerPort = freePorts[1]; - return new Promise((resolve, reject) => { - app.listen(testServerPort, (err) => { - if (err) reject(err); - resolve(); - }); - }); - }); -}); - -after(function () { - this.timeout(5 * 1000); - if (proxyServer) return util.promisify(proxyServer.close).bind(proxyServer)(); -}); - - -const requestPromised = (opts) => { - // console.log('requestPromised'); - // console.dir(opts); - return new Promise((resolve, reject) => { - request(opts, (error, response, body) => { - if (error) return reject(error); - if (response.statusCode !== 200) { - return reject(new Error(`Received invalid response code: ${response.statusCode}`)); - } - if (opts.expectBodyContainsText) expect(body).to.contain(opts.expectBodyContainsText); - resolve(); - }); - }); -}; - - -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)', () => { - 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 = results[0]; - proxyUrl2 = results[1]; - 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; - 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(() => { - return closeAnonymizedProxy(proxyUrl1, true); - }) - .then((closed) => { - expect(closed).to.eql(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); - - // 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, closed) => { - if (err) return reject(err); - resolve(closed); - }); - }); - }) - .then((closed) => { - expect(closed).to.eql(false); - }); - }); - - after(() => { - Object.assign(PORT_SELECTION_CONFIG, PORT_SELECTION_CONFIG); - }); -}); diff --git a/test/e2e/anonymize_proxy.js b/test/e2e/anonymize_proxy.js new file mode 100644 index 00000000..b50ea7de --- /dev/null +++ b/test/e2e/anonymize_proxy.js @@ -0,0 +1,361 @@ +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 '../../src/index.js'; +import { expectThrowsAsync } from '../utils/throws_async.js'; + +let expressServer; +let proxyServer; +let proxyPort; +let testServerPort; +const proxyAuth = { scheme: 'Basic', username: 'username', password: 'password' }; +let wasProxyCalled = false; + +const serverListen = (server, port) => new Promise((resolve, reject) => { + server.once('error', reject); + + server.listen(port, () => { + server.off('error', reject); + + resolve(server.address().port); + }); +}); + +// Setup local proxy server and web server for the tests +before(() => { + // Find free port for the proxy + let freePorts; + return portastic.find({ min: 50000, max: 50100 }) + .then((result) => { + freePorts = result; + return new Promise((resolve, reject) => { + const httpServer = http.createServer(); + + // Setup proxy authorization + httpServer.authenticate = function (req, fn) { + // parse the "Proxy-Authorization" header + const auth = req.headers['proxy-authorization']; + if (!auth) { + // optimization: don't invoke the child process if no + // "Proxy-Authorization" header was given + // console.log('not Proxy-Authorization'); + return fn(null, false); + } + const parsed = basicAuthParser(auth); + const isEqual = _.isEqual(parsed, proxyAuth); + // console.log('Parsed "Proxy-Authorization": parsed: %j expected: %j : %s', parsed, proxyAuth, isEqual); + if (isEqual) wasProxyCalled = true; + fn(null, isEqual); + }; + + httpServer.on('error', reject); + + proxyServer = proxy(httpServer); + proxyServer.listen(freePorts[0], () => { + proxyPort = proxyServer.address().port; + resolve(); + }); + }); + }) + .then(() => { + const app = express(); + + app.get('/', (req, res) => res.send('Hello World!')); + + // eslint-disable-next-line prefer-destructuring + testServerPort = freePorts[1]; + return new Promise((resolve, reject) => { + expressServer = app.listen(testServerPort, () => { + resolve(); + }); + }); + }); +}); + +after(async function () { + this.timeout(5 * 1000); + + await new Promise((resolve) => expressServer.close(resolve)); + + if (proxyServer) await util.promisify(proxyServer.close.bind(proxyServer))(); +}); + +const requestPromised = (opts) => { + // console.log('requestPromised'); + // console.dir(opts); + return new Promise((resolve, reject) => { + request(opts, (error, response, body) => { + if (error) return reject(error); + if (response.statusCode !== 200) { + return reject(new Error(`Received invalid response code: ${response.statusCode}`)); + } + if (opts.expectBodyContainsText) expect(body).to.contain(opts.expectBodyContainsText); + resolve(); + }); + }); +}; + +describe('utils.anonymizeProxy', function () { + // Need larger timeout for Travis CI + this.timeout(5 * 1000); + it('throws for invalid args', () => { + expectThrowsAsync(async () => { await anonymizeProxy(null); }); + expectThrowsAsync(async () => { await anonymizeProxy(); }); + expectThrowsAsync(async () => { await anonymizeProxy({}); }); + + expectThrowsAsync(async () => { await closeAnonymizedProxy({}); }); + expectThrowsAsync(async () => { await closeAnonymizedProxy(); }); + expectThrowsAsync(async () => { await closeAnonymizedProxy(null); }); + }); + + it('throws for unsupported https: protocol', () => { + expectThrowsAsync(async () => { await anonymizeProxy('https://whatever.com'); }); + expectThrowsAsync(async () => { await anonymizeProxy({ url: 'https://whatever.com' }); }); + }); + + it('throws for invalid ports', () => { + expectThrowsAsync(async () => { await anonymizeProxy({ url: 'http://whatever.com', port: -16 }); }); + expectThrowsAsync(async () => { + await anonymizeProxy({ + url: 'http://whatever.com', + port: 4324324324, + }); + }); + }); + + it('throws for invalid URLs', () => { + expectThrowsAsync(async () => { await anonymizeProxy('://whatever.com'); }); + expectThrowsAsync(async () => { await anonymizeProxy('https://whatever.com'); }); + expectThrowsAsync(async () => { await anonymizeProxy({ url: '://whatever.com' }); }); + expectThrowsAsync(async () => { await anonymizeProxy({ url: 'https://whatever.com' }); }); + }); + + it('keeps already anonymous proxies', async () => { + const anonymousProxyUrl = await anonymizeProxy('http://whatever:4567'); + expect(anonymousProxyUrl).to.eql('http://whatever:4567'); + + const anonymousProxyUrl2 = await anonymizeProxy('http://whatever:4567'); + expect(anonymousProxyUrl2).to.eql('http://whatever:4567'); + }); + + 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); + + // Close proxy 1 and verify + const closed1 = await closeAnonymizedProxy(proxyUrl1, true); + expect(closed1).to.eql(true); + + // 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', () => { + const N = 20; + let proxyUrls; + + return Promise.resolve() + .then(() => { + const promises = []; + for (let i = 0; i < N; i++) { + promises.push(anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${proxyPort}`)); + } + + return Promise.all(promises); + }) + .then((results) => { + const promises = []; + proxyUrls = results; + for (let i = 0; i < N; i++) { + expect(proxyUrls[i]).to.not.contain(`${proxyPort}`); + + // Test call through proxy + promises.push(requestPromised({ + uri: `http://localhost:${testServerPort}`, + proxy: proxyUrls[i], + expectBodyContainsText: 'Hello World!', + })); + } + + return Promise.all(promises); + }) + .then(() => { + expect(wasProxyCalled).to.equal(true); + const promises = []; + + for (let i = 0; i < N; i++) { + promises.push(closeAnonymizedProxy(proxyUrls[i], true)); + } + + return Promise.all(promises); + }) + .then((results) => { + for (let i = 0; i < N; i++) { + expect(results[i]).to.eql(true); + } + }); + }); + + it('handles HTTP CONNECT request properly', function () { + this.timeout(50 * 1000); + + const host = `localhost:${testServerPort}`; + let onconnectArgs; + function onconnect(message, socket) { + onconnectArgs = message; + socket.write('HTTP/1.1 401 UNAUTHORIZED\r\n\r\n'); + socket.end(); + socket.destroy(); + } + + const localProxy = http.createServer(); + localProxy.on('connect', onconnect); + + let proxyUrl; + + return serverListen(localProxy, 0) + .then(() => anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${localProxy.address().port}`)) + .then((url) => { + proxyUrl = url; + + return requestPromised({ + uri: `https://${host}`, + proxy: proxyUrl, + }); + }) + .then(() => { + expect(false).to.equal(true); + }, () => { + expect(onconnectArgs.headers.host).to.equal(host); + expect(onconnectArgs.url).to.equal(host); + }) + .finally(() => closeAnonymizedProxy(proxyUrl, true)) + .finally(() => localProxy.close()); + }); + + it('handles HTTP CONNECT callback properly', function () { + this.timeout(50 * 1000); + + const host = `localhost:${testServerPort}`; + let rawHeadersRetrieved; + function onconnect(message, socket) { + socket.write('HTTP/1.1 200 OK\r\nfoo: bar\r\n\r\n'); + socket.end(); + socket.destroy(); + } + + let proxyUrl; + + const localProxy = http.createServer(); + localProxy.on('connect', onconnect); + + return serverListen(localProxy, 0) + .then(() => anonymizeProxy(`http://${proxyAuth.username}:${proxyAuth.password}@127.0.0.1:${localProxy.address().port}`)) + .then((url) => { + proxyUrl = url; + + listenConnectAnonymizedProxy(proxyUrl, ({ response, socket, head }) => { + rawHeadersRetrieved = response.rawHeaders; + }); + return requestPromised({ + uri: `https://${host}`, + proxy: proxyUrl, + }) + .catch(() => {}); + }) + .then(() => { + expect(rawHeadersRetrieved).to.eql(['foo', 'bar']); + }) + .finally(() => closeAnonymizedProxy(proxyUrl, true)) + .finally(() => localProxy.close()); + }); + + it('fails with invalid upstream proxy credentials', () => { + let anonymousProxyUrl; + return Promise.resolve() + .then(() => { + return anonymizeProxy(`http://username:bad-password@127.0.0.1:${proxyPort}`); + }) + .then((result) => { + anonymousProxyUrl = result; + expect(anonymousProxyUrl).to.not.contain(`${proxyPort}`); + wasProxyCalled = false; + return requestPromised({ + uri: 'http://whatever', + proxy: anonymousProxyUrl, + }); + }) + .then(() => { + assert.fail(); + }) + .catch((err) => { + expect(err.message).to.contains('Received invalid response code: 597'); // Gateway error + expect(wasProxyCalled).to.equal(false); + }) + .then(() => closeAnonymizedProxy(anonymousProxyUrl, true)) + .then((closed) => { + expect(closed).to.eql(true); + }); + }); +}); diff --git a/test/e2e/anonymize_proxy_no_password.js b/test/e2e/anonymize_proxy_no_password.js new file mode 100644 index 00000000..ee77dc0c --- /dev/null +++ b/test/e2e/anonymize_proxy_no_password.js @@ -0,0 +1,146 @@ +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 '../../src/index.js'; + +let expressServer; +let proxyServer; +let proxyPort; +let testServerPort; +const proxyAuth = { scheme: 'Basic', username: 'username', password: '' }; +let wasProxyCalled = false; + +// Setup local proxy server and web server for the tests +before(async () => { + const freePorts = await portastic.find({ min: 50000, max: 50100 }); + + await new Promise((resolve, reject) => { + const httpServer = http.createServer(); + + // Setup proxy authorization + httpServer.authenticate = function (req, fn) { + // parse the "Proxy-Authorization" header + const auth = req.headers['proxy-authorization']; + if (!auth) { + // optimization: don't invoke the child process if no + // "Proxy-Authorization" header was given + return fn(null, false); + } + const parsed = basicAuthParser(auth); + const isEqual = _.isEqual(parsed, proxyAuth); + if (isEqual) wasProxyCalled = true; + fn(null, isEqual); + }; + + httpServer.on('error', reject); + + proxyServer = proxy(httpServer); + proxyServer.listen(freePorts[0], () => { + proxyPort = proxyServer.address().port; + resolve(); + }); + }); + + const app = express(); + app.get('/', (req, res) => res.send('Hello World!')); + + testServerPort = freePorts[1]; + await new Promise((resolve) => { + expressServer = app.listen(testServerPort, resolve); + }); +}); + +after(async function () { + this.timeout(5 * 1000); + await new Promise((resolve) => expressServer.close(resolve)); + + if (proxyServer) await util.promisify(proxyServer.close.bind(proxyServer))(); +}); + +const requestPromised = (opts) => { + // console.log('requestPromised'); + // console.dir(opts); + return new Promise((resolve, reject) => { + request(opts, (error, response, body) => { + if (error) return reject(error); + if (response.statusCode !== 200) { + return reject(new Error(`Received invalid response code: ${response.statusCode}`)); + } + if (opts.expectBodyContainsText) expect(body).to.contain(opts.expectBodyContainsText); + resolve(); + }); + }); +}; + + +describe('utils.anonymizeProxyNoPassword', function () { + // Need larger timeout for Travis CI + this.timeout(5 * 1000); + it('anonymizes authenticated with no password 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); + + const closed1 = await closeAnonymizedProxy(proxyUrl1, true); + expect(closed1).to.eql(true); + + // 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); + } + + const closed2 = await closeAnonymizedProxy(proxyUrl2, true); + expect(closed2).to.eql(true); + + // Test the second-time call to close + const closed1Again = await closeAnonymizedProxy(proxyUrl1, true); + expect(closed1Again).to.eql(false); + + const closed2Again = await closeAnonymizedProxy(proxyUrl2, false); + expect(closed2Again).to.eql(false); + }); +}); diff --git a/test/e2e/ee-memory-leak.js b/test/e2e/ee-memory-leak.js new file mode 100644 index 00000000..8f05c786 --- /dev/null +++ b/test/e2e/ee-memory-leak.js @@ -0,0 +1,61 @@ +import net from 'node:net'; +import http from 'node:http'; +import { assert } from 'chai'; +import * as ProxyChain from '../../src/index.js'; + +describe('ProxyChain server', () => { + let server; + let port; + + before(() => { + server = http.createServer((_request, response) => { + response.end('Hello, world!'); + }).listen(0); + + port = server.address().port; + }); + + after(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + it('does not leak events', async () => { + const proxyServer = new ProxyChain.Server(); + + try { + let socket; + let registeredCount; + proxyServer.server.prependOnceListener('request', (request) => { + socket = request.socket; + registeredCount = socket.listenerCount('error'); + }); + + await proxyServer.listen(); + const proxyServerPort = proxyServer.server.address().port; + + const requestCount = 20; + + const client = net.connect({ + host: 'localhost', + port: proxyServerPort, + }); + + client.setTimeout(100); + + await new Promise((resolve) => { + client.on('timeout', () => { + client.destroy(); + 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); + } finally { + await proxyServer.close(true); + } + }); +}); diff --git a/test/e2e/http-agent.js b/test/e2e/http-agent.js new file mode 100644 index 00000000..1e4209b3 --- /dev/null +++ b/test/e2e/http-agent.js @@ -0,0 +1,343 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import http from 'node:http'; +import https from 'node:https'; +import { expect } from 'chai'; +import portastic from 'portastic'; +import proxy from 'proxy'; +import request from 'request'; + +import { Server } from '../../src/index.js'; +import { TargetServer } from '../utils/target_server.js'; + +const sslKey = fs.readFileSync(path.join(import.meta.dirname, 'ssl.key')); +const sslCrt = fs.readFileSync(path.join(import.meta.dirname, 'ssl.crt')); + +describe('HTTP Agent Support', () => { + let mainProxyServer; + let mainProxyServerPort; + let upstreamProxyServer; + let upstreamProxyPort; + let targetServer; + let targetServerUrl; + + before(async () => { + // Get free ports + const freePorts = await portastic.find({ min: 50000, max: 50100 }); + + // Setup target server + const targetServerPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetServerPort, + useSsl: false, + }); + await targetServer.listen(); + targetServerUrl = `http://localhost:${targetServerPort}`; + + // Setup upstream proxy server + upstreamProxyPort = freePorts.shift(); + await new Promise((resolve, reject) => { + upstreamProxyServer = proxy(http.createServer()); + upstreamProxyServer.listen(upstreamProxyPort, (err) => { + if (err) return reject(err); + resolve(); + }); + }); + + // Setup main proxy server with custom agents + mainProxyServerPort = freePorts.shift(); + }); + + after(async () => { + if (targetServer) await targetServer.close(); + if (upstreamProxyServer) await new Promise((resolve) => upstreamProxyServer.close(resolve)); + if (mainProxyServer) await mainProxyServer.close(true); + }); + + it('httpAgent smoke test - no exceptions', async () => { + const httpAgent = new http.Agent({ keepAlive: true }); + const httpsAgent = new https.Agent({ keepAlive: true }); + + if (mainProxyServer) await mainProxyServer.close(true); + + mainProxyServer = new Server({ + port: mainProxyServerPort, + prepareRequestFunction: () => { + return { + upstreamProxyUrl: `http://localhost:${upstreamProxyPort}`, + httpAgent, + httpsAgent, + }; + }, + }); + + await mainProxyServer.listen(); + + // Make HTTP request through the proxy + await new Promise((resolve, reject) => { + request({ + url: `${targetServerUrl}/hello-world`, + proxy: `http://localhost:${mainProxyServerPort}`, + }, (error, response) => { + if (error) return reject(error); + expect(response.statusCode).to.eql(200); + resolve(); + }); + }); + + // Cleanup agents + httpAgent.destroy(); + httpsAgent.destroy(); + }); + + it('works without agents (backward compatibility)', async () => { + if (mainProxyServer) await mainProxyServer.close(true); + + mainProxyServer = new Server({ + port: mainProxyServerPort, + prepareRequestFunction: () => { + return { + upstreamProxyUrl: `http://localhost:${upstreamProxyPort}`, + // No agents provided - should work fine + }; + }, + }); + + await mainProxyServer.listen(); + + // Make HTTP request through the proxy + await new Promise((resolve, reject) => { + request({ + url: `${targetServerUrl}/hello-world`, + proxy: `http://localhost:${mainProxyServerPort}`, + }, (error, response) => { + if (error) return reject(error); + expect(response.statusCode).to.eql(200); + resolve(); + }); + }); + }); + + it('preserves getConnectionStats with agents', async () => { + if (mainProxyServer) await mainProxyServer.close(true); + + const httpAgent = new http.Agent({ keepAlive: true }); + let connectionId; + + mainProxyServer = new Server({ + port: mainProxyServerPort, + prepareRequestFunction: ({ connectionId: id }) => { + connectionId = id; + return { + upstreamProxyUrl: `http://localhost:${upstreamProxyPort}`, + httpAgent, + }; + }, + }); + + await mainProxyServer.listen(); + + // Make HTTP request + await new Promise((resolve, reject) => { + request({ + url: `${targetServerUrl}/hello-world`, + proxy: `http://localhost:${mainProxyServerPort}`, + forever: true, // Keep socket alive + }, (error, response) => { + if (error) return reject(error); + expect(response.statusCode).to.eql(200); + + // Keep the connection alive briefly to check stats + setImmediate(() => resolve()); + }); + }); + + // Verify getConnectionStats works while connection may still be open + expect(connectionId).to.be.a('number'); + const stats = mainProxyServer.getConnectionStats(connectionId); + expect(stats).to.be.an('object'); + expect(stats.srcTxBytes).to.be.a('number'); + expect(stats.srcTxBytes).to.be.greaterThan(0); + expect(stats.srcRxBytes).to.be.a('number'); + expect(stats.srcRxBytes).to.be.greaterThan(0); + expect(stats.trgTxBytes).to.be.a('number'); + expect(stats.trgTxBytes).to.be.greaterThan(0); + expect(stats.trgRxBytes).to.be.a('number'); + expect(stats.trgRxBytes).to.be.greaterThan(0); + + httpAgent.destroy(); + }); + + it('works with HTTPS targets using CONNECT tunneling', async () => { + if (mainProxyServer) await mainProxyServer.close(true); + + // Close existing HTTP target server + const originalTargetServer = targetServer; + const originalTargetServerUrl = targetServerUrl; + await targetServer.close(); + + // Setup HTTPS target server on new port. Use different range to avoid conflicts with http server + const httpsFreePorts = await portastic.find({ min: 50100, max: 50200 }); + const httpsTargetPort = httpsFreePorts.shift(); + + targetServer = new TargetServer({ + port: httpsTargetPort, + useSsl: true, + sslKey, + sslCrt, + }); + await targetServer.listen(); + const httpsTargetUrl = `https://localhost:${httpsTargetPort}`; + + const httpAgent = new http.Agent({ + keepAlive: true, + maxSockets: 1, + }); + + let requestCount = 0; + + mainProxyServer = new Server({ + port: mainProxyServerPort, + prepareRequestFunction: () => { + requestCount++; + return { + upstreamProxyUrl: `http://localhost:${upstreamProxyPort}`, + httpAgent, + }; + }, + }); + + await mainProxyServer.listen(); + + // Make multiple HTTPS requests through CONNECT tunnel + for (let i = 0; i < 2; i++) { + await new Promise((resolve, reject) => { + request({ + url: `${httpsTargetUrl}/hello-world`, + proxy: `http://localhost:${mainProxyServerPort}`, + strictSSL: false, // Allow self-signed cert + }, (error, response) => { + if (error) return reject(error); + expect(response.statusCode).to.eql(200); + resolve(); + }); + }); + } + + // Verify both requests were handled + expect(requestCount).to.eql(2); + + httpAgent.destroy(); + + // Restore original HTTP target server + await targetServer.close(); + targetServer = originalTargetServer; + targetServerUrl = originalTargetServerUrl; + await targetServer.listen(); + }); + + it('pools connections with HTTP upstream proxy', async () => { + if (mainProxyServer) await mainProxyServer.close(true); + if (upstreamProxyServer) await new Promise((resolve) => upstreamProxyServer.close(resolve)); + + let httpUpstreamConnectionCount = 0; + + // Setup HTTP upstream proxy with connection tracking + await new Promise((resolve, reject) => { + const httpServer = http.createServer(); + httpServer.on('connection', () => { + httpUpstreamConnectionCount++; + }); + + upstreamProxyServer = proxy(httpServer); + upstreamProxyServer.listen(upstreamProxyPort, (err) => { + if (err) return reject(err); + resolve(); + }); + }); + + const httpAgent = new http.Agent({ + keepAlive: true, + maxSockets: 1, + }); + + mainProxyServer = new Server({ + port: mainProxyServerPort, + prepareRequestFunction: () => { + return { + upstreamProxyUrl: `http://localhost:${upstreamProxyPort}`, + httpAgent, + }; + }, + }); + + await mainProxyServer.listen(); + + // Make multiple HTTP requests through HTTP upstream proxy + for (let i = 0; i < 3; i++) { + await new Promise((resolve, reject) => { + request({ + url: `${targetServerUrl}/hello-world`, + proxy: `http://localhost:${mainProxyServerPort}`, + }, (error, response) => { + if (error) return reject(error); + expect(response.statusCode).to.eql(200); + resolve(); + }); + }); + } + + // Verify httpAgent pools connections to HTTP upstream (1 connection for 3 requests) + expect(httpUpstreamConnectionCount).to.eql(1, 'httpAgent should pool connections to HTTP upstream'); + + httpAgent.destroy(); + }); + + it('works with HTTPS upstream proxy', async () => { + if (mainProxyServer) await mainProxyServer.close(true); + + const httpsAgent = new https.Agent({ keepAlive: true }); + + let httpsUpstreamRequests = 0; + + mainProxyServer = new Server({ + port: mainProxyServerPort, + prepareRequestFunction: () => { + httpsUpstreamRequests++; + return { + // Use non-existent HTTPS upstream - request will fail but proves code path works + upstreamProxyUrl: `https://non-existent-https-proxy.example.com:8080`, + ignoreUpstreamProxyCertificate: true, + httpsAgent, + }; + }, + }); + + await mainProxyServer.listen(); + + // Make request - will fail to connect to non-existent HTTPS upstream + let errorOccurred = false; + await new Promise((resolve) => { + request({ + url: `${targetServerUrl}/hello-world`, + proxy: `http://localhost:${mainProxyServerPort}`, + timeout: 2000, + }, (error, response) => { + if (error) { + errorOccurred = true; + } else if (response && response.statusCode >= 500) { + // 5xx error from proxy indicates upstream connection issue + errorOccurred = true; + } + resolve(); + }); + }); + + // Verify prepareRequestFunction was called with HTTPS upstream + expect(httpsUpstreamRequests).to.eql(1); + // Request should fail or return 5xx due to non-existent HTTPS upstream + expect(errorOccurred).to.be.true; + + httpsAgent.destroy(); + }); +}); diff --git a/test/e2e/https-server.js b/test/e2e/https-server.js new file mode 100644 index 00000000..7b4b78cd --- /dev/null +++ b/test/e2e/https-server.js @@ -0,0 +1,362 @@ +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import tls from 'node:tls'; +import { expect } from 'chai'; +import http from 'node:http'; +import { Server } from '../../src/index.js'; + +const sslKey = fs.readFileSync(path.join(import.meta.dirname, 'ssl.key')); +const sslCrt = fs.readFileSync(path.join(import.meta.dirname, 'ssl.crt')); + +const wait = (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)); + +it('handles TLS handshake failures gracefully and continues accepting connections', async function () { + this.timeout(10000); + + const tlsErrors = []; + 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', + httpsOptions: { + key: sslKey, + cert: sslCrt, + }, + }); + + server.on('tlsError', ({ error }) => { + tlsErrors.push(error); + }); + + await server.listen(); + const serverPort = server.port; + + // Make invalid TLS connection. + badSocket = tls.connect({ + port: serverPort, + host: '127.0.0.1', + rejectUnauthorized: false, + minVersion: 'TLSv1', + maxVersion: 'TLSv1', + }); + + const badSocketErrorOccurred = await new Promise((resolve, reject) => { + let errorOccurred = false; + + badSocket.on('error', () => { + errorOccurred = true; + // Expected: TLS handshake will fail due to version mismatch. + }); + + badSocket.on('close', () => { + resolve(errorOccurred); + }); + + badSocket.setTimeout(5000, () => { + badSocket.destroy(); + reject(new Error('Bad socket timed out before error')); + }); + + }); + + await wait(100); + + expect(badSocketErrorOccurred).to.equal(true); + + // Make a valid TLS connection to prove server still works. + goodSocket = tls.connect({ + port: serverPort, + host: '127.0.0.1', + rejectUnauthorized: false, + }); + + // Wait for secure connection. + const goodSocketConnected = await new Promise((resolve, reject) => { + let isConnected = false; + + const timeout = setTimeout(() => { + goodSocket.destroy(); + reject(new Error('Good socket connection timed out')); + }, 5000); + + goodSocket.on('error', (err) => { + clearTimeout(timeout); + goodSocket.destroy(); + reject(err); + }); + + goodSocket.on('secureConnect', () => { + isConnected = true; + clearTimeout(timeout); + resolve(isConnected); + }); + + goodSocket.on('close', () => { + clearTimeout(timeout); + }); + }); + + expect(goodSocketConnected).to.equal(true, 'Good socket should have connected'); + + // 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(() => { + goodSocket.destroy(); + reject(new Error('Good socket connection timed out')); + }, 5000); + + goodSocket.on('error', (err) => { + clearTimeout(goodSocketTimeout); + goodSocket.destroy(); + reject(err); + }); + + goodSocket.on('data', (data) => { + clearTimeout(goodSocketTimeout); + goodSocket.destroy(); + resolve(data.toString()); + }); + + goodSocket.on('close', () => { + clearTimeout(goodSocketTimeout); + }); + }); + + await wait(100); + + expect(response).to.be.equal('HTTP/1.1 200 Connection Established\r\n\r\n'); + + expect(tlsErrors.length).to.be.equal(1); + expect(tlsErrors[0].library).to.be.equal('SSL routines'); + // 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(); + } + if (goodSocket && !goodSocket.destroyed) { + goodSocket.destroy(); + } + if (server) { + await server.close(true); + } + if (targetServer) { + await new Promise((resolve) => targetServer.close(resolve)); + } + } +}); + +describe('HTTPS proxy server resource cleanup', () => { + let server; + + beforeEach(async () => { + server = new Server({ + port: 0, + serverType: 'https', + httpsOptions: { + key: sslKey, + cert: sslCrt, + }, + }); + await server.listen(); + }); + + afterEach(async () => { + if (server) { + await server.close(true); + server = null; + } + }); + + it('cleans up connections when client disconnects abruptly', async function () { + this.timeout(5000); + + const closedConnections = []; + server.on('connectionClosed', ({ connectionId }) => { + closedConnections.push(connectionId); + }); + + const socket = tls.connect({ + port: server.port, + host: '127.0.0.1', + rejectUnauthorized: false, + }); + + await new Promise((resolve) => socket.on('secureConnect', resolve)); + + // Small delay to ensure server-side connection registration completes. + await wait(100); + + const connectionsBefore = server.getConnectionIds().length; + expect(connectionsBefore).to.equal(1); + + // Abruptly destroy the connection (simulating client crash). + socket.destroy(); + + await new Promise((resolve) => socket.on('close', resolve)); + await wait(100); + + expect(server.getConnectionIds()).to.be.empty; + expect(closedConnections.length).to.equal(1); + }); + + it('cleans up when client closes immediately after CONNECT 200', async function () { + this.timeout(5000); + + const closedConnections = []; + server.on('connectionClosed', ({ connectionId, stats }) => { + closedConnections.push({ connectionId, stats }); + }); + + const socket = tls.connect({ + port: server.port, + host: '127.0.0.1', + rejectUnauthorized: false, + }); + + await new Promise((resolve) => socket.on('secureConnect', resolve)); + + socket.write('CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n'); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timeout waiting for CONNECT response')), 3000); + + socket.on('data', (data) => { + if (data.toString().includes('200')) { + clearTimeout(timeout); + socket.destroy(); // Abrupt close. + resolve(); + } + }); + + socket.on('error', () => {}); + }); + + await new Promise((resolve) => socket.on('close', resolve)); + await wait(500); + + expect(server.getConnectionIds()).to.be.empty; + expect(closedConnections.length).to.equal(1); + }); + + it('handles multiple HTTP requests over single TLS connection (keep-alive)', async function () { + this.timeout(10000); + + const targetServer = http.createServer((_, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello world!'); + }); + + await new Promise((resolve) => targetServer.listen(0, resolve)); + const targetServerPort = targetServer.address().port; + + try { + const socket = tls.connect({ + port: server.port, + host: '127.0.0.1', + rejectUnauthorized: false, + }); + + await new Promise((resolve) => socket.on('secureConnect', resolve)); + + const responses = []; + + for (let i = 0; i < 3; i++) { + socket.write( + `GET http://127.0.0.1:${targetServerPort}/hello-world HTTP/1.1\r\n` + + `Host: 127.0.0.1\r\n` + + `Connection: keep-alive\r\n\r\n` + ); + + const response = await new Promise((resolve) => { + let data = ''; + const onData = (chunk) => { + data += chunk.toString(); + if (data.includes('Hello world')) { + socket.removeListener('data', onData); + resolve(data); + } + }; + socket.on('data', onData); + }); + + responses.push(response); + + // Verify keep-alive: socket still alive, exactly one connection. + expect(socket.destroyed).to.equal(false); + expect(server.getConnectionIds().length).to.equal(1); + } + + socket.destroy(); + + // Wait a bit for socket cleanup. + await wait(100); + + expect(server.getConnectionIds().length).to.equal(0); + + expect(responses.length).to.equal(3); + responses.forEach((r) => { + expect(r).to.include('200 OK'); + expect(r).to.include('Hello world'); + }); + } finally { + await new Promise((resolve) => targetServer.close(resolve)); + } + }); + + it('handles multiple sequential TLS failures without leaking connections', async function () { + this.timeout(10000); + + const tlsErrors = []; + server.on('tlsError', ({ error }) => tlsErrors.push(error)); + + // 10 sequential failures (sanity check). + for (let i = 0; i < 10; i++) { + const badSocket = tls.connect({ + port: server.port, + host: '127.0.0.1', + minVersion: 'TLSv1', + maxVersion: 'TLSv1', + }); + + await new Promise((resolve) => { + badSocket.on('error', () => {}); + badSocket.on('close', resolve); + }); + } + + await wait(200); + + expect(tlsErrors.length).to.equal(10); + expect(server.getConnectionIds()).to.be.empty; + + // Verify server still works. + const goodSocket = tls.connect({ + port: server.port, + host: '127.0.0.1', + rejectUnauthorized: false, + }); + + await new Promise((resolve, reject) => { + goodSocket.on('secureConnect', resolve); + goodSocket.on('error', reject); + }); + + goodSocket.destroy(); + }); +}); diff --git a/test/e2e/https-stress-test.js b/test/e2e/https-stress-test.js new file mode 100644 index 00000000..52398bc6 --- /dev/null +++ b/test/e2e/https-stress-test.js @@ -0,0 +1,169 @@ +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 request from 'request'; +import { expect } from 'chai'; +import { Server } from '../../src/index.js'; +import { TargetServer } from '../utils/target_server.js'; + +// 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(import.meta.dirname, 'ssl.key')); +const sslCrt = fs.readFileSync(path.join(import.meta.dirname, 'ssl.crt')); + +const requestPromised = util.promisify(request); + +describe('HTTPS proxy stress testing', function () { + this.timeout(60000); + + let server; + let targetServer; + let targetServerPort; + + before(async () => { + targetServer = new TargetServer({ port: 0, useSsl: false }); + await targetServer.listen(); + targetServerPort = targetServer.httpServer.address().port; + }); + + after(async () => { + if (targetServer) await targetServer.close(); + }); + + beforeEach(async () => { + server = new Server({ + port: 0, + 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(); + }); + + afterEach(async () => { + if (server) await server.close(true); + }); + + it('handles 100 concurrent HTTP requests with correct responses', async () => { + const REQUESTS = 100; + const results = []; + + const promises = []; + for (let i = 0; i < REQUESTS; i++) { + promises.push( + requestPromised({ + url: `http://127.0.0.1:${targetServerPort}/hello-world`, + proxy: `https://127.0.0.1:${server.port}`, + strictSSL: false, + }).then((response) => { + results.push({ + status: response.statusCode, + body: response.body, + }); + }).catch((err) => { + results.push({ error: err.message }); + }) + ); + } + + await Promise.all(promises); + + const successful = results.filter((r) => r.status === 200 && r.body === 'Hello world!'); + expect(successful.length).to.equal(REQUESTS); + }); + + // Not specific for https but still worth to have. + it('handles 100 concurrent CONNECT tunnels with data verification', async () => { + const TUNNEL_COUNT = 100; + const results = []; + + const promises = []; + for (let i = 0; i < TUNNEL_COUNT; i++) { + promises.push(new Promise((resolve) => { + const socket = tls.connect({ + port: server.port, + host: '127.0.0.1', + rejectUnauthorized: false, + }); + + let requestSent = false; + + socket.on('secureConnect', () => { + socket.write(`CONNECT 127.0.0.1:${targetServerPort} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n`); + }); + + let data = ''; + socket.on('data', (chunk) => { + data += chunk.toString(); + + if (data.includes('200 Connection Established') && !requestSent) { + requestSent = true; + socket.write('GET /hello-world HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n'); + } + + if (data.includes('Hello world')) { + socket.destroy(); + results.push({ success: true }); + resolve(); + } + }); + + socket.on('error', (err) => { + results.push({ error: err.message }); + resolve(); + }); + + setTimeout(() => { + socket.destroy(); + if (!results.some((r) => r.success || r.error)) { + results.push({ error: 'timeout' }); + } + resolve(); + }, 10000); + })); + } + + await Promise.all(promises); + + const successful = results.filter((r) => r.success); + expect(successful.length).to.equal(TUNNEL_COUNT); + }); + + it('tracks accurate statistics for 100 concurrent requests', async () => { + const REQUESTS = 100; + const allStats = []; + + server.on('connectionClosed', ({ stats }) => { + allStats.push(stats); + }); + + const promises = []; + for (let i = 0; i < REQUESTS; i++) { + promises.push( + requestPromised({ + url: `http://127.0.0.1:${targetServerPort}/hello-world`, + proxy: `https://127.0.0.1:${server.port}`, + strictSSL: false, + }) + ); + } + + await Promise.all(promises); + await new Promise((r) => setTimeout(r, 500)); + + expect(allStats.length).to.equal(REQUESTS); + + allStats.forEach((stats) => { + // These are application-layer bytes only (no TLS overhead). + // srcRxBytes > trgTxBytes because hop-by-hop headers (e.g., Proxy-Connection) + // are stripped when forwarding the request to target. + expect(stats).to.be.deep.equal({ srcTxBytes: 174, srcRxBytes: 93, trgTxBytes: 71, trgRxBytes: 174 }); + }); + }); +}); diff --git a/test/server.js b/test/e2e/server.js similarity index 51% rename from test/server.js rename to test/e2e/server.js index 0a4eb9d6..890a19c8 100644 --- a/test/server.js +++ b/test/e2e/server.js @@ -1,21 +1,24 @@ -const fs = require('fs'); -const path = require('path'); -const stream = require('stream'); -const childProcess = require('child_process'); -const net = require('net'); -const dns = require('dns'); -const util = require('util'); -const _ = require('underscore'); -const { expect, assert } = require('chai'); -const proxy = require('proxy'); -const http = require('http'); -const portastic = require('portastic'); -const request = require('request'); -const WebSocket = require('faye-websocket'); - -const { parseUrl, parseProxyAuthorizationHeader } = require('../build/tools'); -const { Server, RequestError } = require('../build/index'); -const { TargetServer } = require('./target_server'); +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 { 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 '../../src/utils/parse_authorization_header.js'; +import { Server, RequestError } from '../../src/index.js'; +import { TargetServer } from '../utils/target_server.js'; /* TODO - add following tests: @@ -28,8 +31,8 @@ TODO - add following tests: // See README.md for details const LOCALHOST_TEST = 'localhost-test'; -const sslKey = fs.readFileSync(path.join(__dirname, 'ssl.key')); -const sslCrt = fs.readFileSync(path.join(__dirname, 'ssl.crt')); +const sslKey = fs.readFileSync(path.join(import.meta.dirname, 'ssl.key')); +const sslCrt = fs.readFileSync(path.join(import.meta.dirname, 'ssl.crt')); // Enable self-signed certificates process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; @@ -53,12 +56,8 @@ const AUTH_REALM = 'Test Proxy'; // Test space in realm string const requestPromised = (opts) => { return new Promise((resolve, reject) => { - const result = request(opts, (error, response, body) => { + request(opts, (error, response, body) => { if (error) { - /* console.log('REQUEST'); - console.dir(response); - console.dir(body); - console.dir(result); */ return reject(error); } resolve(response, body); @@ -66,34 +65,73 @@ const requestPromised = (opts) => { }); }; -const wait = (timeout) => new Promise(resolve => setTimeout(resolve, timeout)); +const wait = (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)); + +// Chromium occasionally fails to spawn under headless Docker (dbus/crashpad noise + ENOENT-ish exits). +// Retry briefly so a single flaky launch doesn't fail the whole suite. +const launchPuppeteer = async (puppeteer, launchOpts) => { + const MAX_ATTEMPTS = 3; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + return await puppeteer.launch(launchOpts); + } catch (error) { + if (attempt === MAX_ATTEMPTS) throw error; + await new Promise((resolve) => setTimeout(resolve, 500 * attempt)); + } + } +}; -// Opens web page in phantomjs and returns the HTML content -const phantomGet = (url, proxyUrl) => { - const phantomPath = path.join(__dirname, '../node_modules/.bin/phantomjs'); - const scriptPath = path.join(__dirname, './phantom_get.js'); +// Opens web page in puppeteer and returns the HTML content +const puppeteerGet = async (url, proxyUrl) => { + const { default: puppeteer } = await import('puppeteer'); - let proxyParams = ''; - if (proxyUrl) { - const parsed = parseUrl(proxyUrl); - proxyParams += `--proxy-type=http --proxy=${parsed.hostname}:${parsed.port} `; - if (parsed.username || parsed.password) { - if ((parsed.username && !parsed.password) || (!parsed.username && parsed.password)) { - throw new Error('PhantomJS cannot handle proxy only username or password!'); - } - proxyParams += `--proxy-auth=${parsed.username}:${parsed.password} `; + const parsed = proxyUrl ? new URL(proxyUrl) : undefined; + + const args = [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + ]; + + const launchOpts = { + acceptInsecureCerts: true, + headless: true, + args + }; + + 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, + }; } } - return new Promise((resolve, reject) => { - const cmd = `${phantomPath} --ignore-ssl-errors=true ${proxyParams} ${scriptPath} ${url}`; - childProcess.exec(cmd, (error, stdout, stderr) => { - if (error) { - return reject(new Error(`Cannot open page in PhantomJS: ${error}: ${stderr || stdout}`)); - } - resolve(stdout); - }); - }); + const browser = await launchPuppeteer(puppeteer, launchOpts); + + 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. @@ -102,8 +140,13 @@ const phantomGet = (url, proxyUrl) => { // This is a regression test for that situation const curlGet = (url, proxyUrl, returnResponse) => { let cmd = 'curl --insecure '; // ignore SSL errors - if (proxyUrl) cmd += `-x ${proxyUrl} `; // use proxy - if (returnResponse) cmd += `--silent --output - ${url}`; // print response to stdout + if (proxyUrl) { + if (proxyUrl.startsWith('https://')) { + cmd += '--proxy-insecure '; + } + cmd += `-x ${proxyUrl} `; // use proxy + } + if (returnResponse) cmd += `--silent --show-error --output - ${url}`; // print response to stdout else cmd += `${url}`; // console.log(`curlGet(): ${cmd}`); @@ -121,7 +164,7 @@ const curlGet = (url, proxyUrl, returnResponse) => { * @return {function(...[*]=)} */ const createTestSuite = ({ - useSsl, useMainProxy, mainProxyAuth, useUpstreamProxy, upstreamProxyAuth, testCustomResponse, + useSsl, useMainProxy, mainProxyAuth, mainProxyServerType, useUpstreamProxy, upstreamProxyAuth, testCustomResponse, }) => { return function () { this.timeout(30 * 1000); @@ -133,6 +176,8 @@ const createTestSuite = ({ let upstreamProxyServer; let upstreamProxyPort; + // eslint is dumb + // eslint-disable-next-line no-unused-vars let upstreamProxyWasCalled = false; let upstreamProxyRequestCount = 0; @@ -140,21 +185,37 @@ const createTestSuite = ({ let mainProxyServerStatisticsInterval; const mainProxyServerConnections = {}; let mainProxyServerPort; + // eslint is dumb + // eslint-disable-next-line no-unused-vars const mainProxyRequestCount = 0; const mainProxyServerConnectionIds = []; const mainProxyServerConnectionsClosed = []; const mainProxyServerConnectionId2Stats = {}; + let upstreamProxyHostname = '127.0.0.1'; + let baseUrl; let mainProxyUrl; const getRequestOpts = (pathOrUrl) => { - return { + const opts = { 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, }; + + // Accept self-signed certificates when connecting to HTTPS proxy. + if (mainProxyServerType === 'https') { + opts.strictSSL = false; + opts.rejectUnauthorized = false; + } + + return opts; }; let counter = 0; @@ -175,6 +236,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++; @@ -194,10 +259,10 @@ const createTestSuite = ({ return fn(null, false); } - const parsed = parseProxyAuthorizationHeader(auth); - const isEqual = _.isEqual(parsed, upstreamProxyAuth); + const parsed = parseAuthorizationHeader(auth); + const isEqual = ['type', 'username', 'password'].every((name) => parsed[name] === upstreamProxyAuth[name]); // console.log('Parsed "Proxy-Authorization": parsed: %j expected: %j : %s', parsed, upstreamProxyAuth, isEqual); - if (isEqual) upstreamProxyWasCalled = true; + if (upstreamProxyAuth === null || isEqual) upstreamProxyWasCalled = true; fn(null, isEqual); }; @@ -265,13 +330,23 @@ const createTestSuite = ({ throw new RequestError('Known error 2', 501); } + if (hostname === 'test-custom-response-buffer.gov') { + result.customResponseFunction = () => { + return { + statusCode: 200, + headers: { + 'content-encoding': 'gzip', + }, + body: zlib.gzipSync('Hello, world!'), + }; + }; + } + if (hostname === 'test-custom-response-simple.gov') { result.customResponseFunction = () => { - const trgParsed = parseUrl(request.url); - expect(trgParsed).to.deep.include({ - host: hostname, - path: '/some/path' - }); + const trgParsed = new URL(request.url); + expect(trgParsed.host).to.be.eql(hostname); + expect(trgParsed.pathname).to.be.eql('/some/path'); return { body: 'TEST CUSTOM RESPONSE SIMPLE', }; @@ -283,11 +358,10 @@ const createTestSuite = ({ if (hostname === 'test-custom-response-complex.gov') { result.customResponseFunction = () => { - const trgParsed = parseUrl(request.url); - expect(trgParsed).to.deep.include({ - hostname, - path: '/some/path?query=456', - }); + const trgParsed = new URL(request.url); + expect(trgParsed.hostname).to.be.eql(hostname); + expect(trgParsed.pathname).to.be.eql('/some/path'); + expect(trgParsed.search).to.be.eql('?query=456'); expect(port).to.be.eql(1234); return { statusCode: 201, @@ -302,11 +376,9 @@ const createTestSuite = ({ if (hostname === 'test-custom-response-long.gov') { result.customResponseFunction = () => { - const trgParsed = parseUrl(request.url); - expect(trgParsed).to.deep.include({ - host: hostname, - path: '/' - }); + const trgParsed = new URL(request.url); + expect(trgParsed.host).to.be.eql(hostname); + expect(trgParsed.pathname).to.be.eql('/'); return { body: 'X'.repeat(5000000), }; @@ -315,11 +387,9 @@ const createTestSuite = ({ if (hostname === 'test-custom-response-promised.gov') { result.customResponseFunction = () => { - const trgParsed = parseUrl(request.url); - expect(trgParsed).to.deep.include({ - host: hostname, - path: '/some/path' - }); + const trgParsed = new URL(request.url); + expect(trgParsed.host).to.be.eql(hostname); + expect(trgParsed.pathname).to.be.eql('/some/path'); return Promise.resolve().then(() => { return { body: 'TEST CUSTOM RESPONSE PROMISED', @@ -334,7 +404,9 @@ const createTestSuite = ({ } if (mainProxyAuth) { - if (mainProxyAuth.username !== username || mainProxyAuth.password !== password) { + const authDoesNotMatch = mainProxyAuth.username !== username || mainProxyAuth.password !== password; + const nopassword = username === 'nopassword' && password === ''; + if (authDoesNotMatch && !nopassword) { result.requestAuthentication = true; addToMainProxyServerConnectionIds = false; // Now that authentication is requested, upstream proxy should not get used, @@ -363,8 +435,11 @@ const createTestSuite = ({ } else { let auth = ''; // NOTE: We URI-encode just username, not password, which might contain - if (upstreamProxyAuth) auth = `${encodeURIComponent(upstreamProxyAuth.username)}:${upstreamProxyAuth.password}@`; - upstreamProxyUrl = `http://${auth}127.0.0.1:${upstreamProxyPort}`; + if (upstreamProxyAuth) { + auth = `${encodeURIComponent(upstreamProxyAuth.username)}:${encodeURIComponent(upstreamProxyAuth.password)}@`; + } + + upstreamProxyUrl = `http://${auth}${upstreamProxyHostname}:${upstreamProxyPort}`; } result.upstreamProxyUrl = upstreamProxyUrl; @@ -387,10 +462,23 @@ const createTestSuite = ({ opts.authRealm = AUTH_REALM; + // Configure HTTPS proxy server if requested. + if (mainProxyServerType === 'https') { + opts.serverType = 'https'; + opts.httpsOptions = { + key: sslKey, + cert: sslCrt, + }; + } + 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.toString()); + assert.include(mainProxyServer.getConnectionIds(), connectionId); mainProxyServerConnectionsClosed.push(connectionId); const index = mainProxyServerConnectionIds.indexOf(connectionId); mainProxyServerConnectionIds.splice(index, 1); @@ -413,7 +501,8 @@ const createTestSuite = ({ if (useMainProxy) { let auth = ''; if (mainProxyAuth) auth = `${mainProxyAuth.username}:${mainProxyAuth.password}@`; - mainProxyUrl = `http://${auth}127.0.0.1:${mainProxyServerPort}`; + const proxySchema = mainProxyServerType === 'https' ? 'https' : 'http'; + mainProxyUrl = `${proxySchema}://${auth}127.0.0.1:${mainProxyServerPort}`; } }); }); @@ -438,12 +527,12 @@ const createTestSuite = ({ return promise.then(() => { assert.fail(); }) - .catch((err) => { - expect(err.message).to.contain(`${expectedStatusCode}`); - }) - .finally(() => { - mainProxyServer.removeListener('requestFailed', onRequestFailed); - }); + .catch((err) => { + expect(err.message.slice(-3)).to.contain(`${expectedStatusCode}`); + }) + .finally(() => { + mainProxyServer.removeListener('requestFailed', onRequestFailed); + }); } return promise.then((response) => { expect(response.statusCode).to.eql(expectedStatusCode); @@ -457,25 +546,99 @@ const createTestSuite = ({ } return response; }) - .finally(() => { - mainProxyServer.removeListener('requestFailed', onRequestFailed); - }); + .finally(() => { + mainProxyServer.removeListener('requestFailed', onRequestFailed); + }); }; // Replacement for it() that checks whether the tests really called the main and upstream proxies // Only use this for requests that are supposed to go through, e.g. not invalid credentials + // eslint-disable-next-line no-underscore-dangle const _it = (description, func) => { it(description, () => { const upstreamCount = upstreamProxyRequestCount; const mainCount = mainProxyServer ? mainProxyServer.stats.connectRequestCount + mainProxyServer.stats.httpRequestCount : null; return func() .then(() => { - if (useMainProxy) expect(mainCount).to.be.below(mainProxyServer.stats.connectRequestCount + mainProxyServer.stats.httpRequestCount); - if (useUpstreamProxy) expect(upstreamCount).to.be.below(upstreamProxyRequestCount); + if (useMainProxy) { + expect(mainCount).to.be.below(mainProxyServer.stats.connectRequestCount + mainProxyServer.stats.httpRequestCount); + } + + if (useUpstreamProxy) { + expect(upstreamCount).to.be.below(upstreamProxyRequestCount); + } }); }); }; + if (useUpstreamProxy) { + _it('upstream ipv6', async () => { + upstreamProxyHostname = '[::1]'; + const opts = getRequestOpts('/hello-world'); + + try { + const response = await requestPromised(opts); + + expect(response.body).to.eql('Hello world!'); + expect(response.statusCode).to.eql(200); + } finally { + upstreamProxyHostname = '127.0.0.1'; + } + }); + } else if (useMainProxy && process.versions.node.split('.')[0] >= 15 && mainProxyServerType !== 'https') { + // Version check is required because HTTP/2 negotiation + // is not supported on Node.js < 15. + // Note: Skipped for HTTPS proxy - got-scraping has issues with IPv6 + HTTPS proxy combination. + + _it('direct ipv6', async () => { + const opts = getRequestOpts('/hello-world'); + opts.url = opts.url.replace('127.0.0.1', '[::1]'); + + // `request` proxy implementation fails to normalize IPv6. + // `got-scraping` normalizes IPv6 properly. + const response = await gotScraping({ + url: opts.url, + headers: opts.headers, + timeout: { + request: opts.timeout, + }, + proxyUrl: opts.proxy, + https: { + key: opts.key, + }, + }); + + expect(response.body).to.eql('Hello world!'); + expect(response.statusCode).to.eql(200); + }); + } else if (!useSsl && process.versions.node.split('.')[0] >= 15 && mainProxyServerType !== 'https') { + // Version check is required because HTTP/2 negotiation + // is not supported on Node.js < 15. + // Note: Skipped for HTTPS proxy - got-scraping has issues with IPv6 + HTTPS proxy combination. + + _it('forward ipv6', async () => { + const opts = getRequestOpts('/hello-world'); + opts.url = opts.url.replace('127.0.0.1', '[::1]'); + + // `request` proxy implementation fails to normalize IPv6. + // `got-scraping` normalizes IPv6 properly. + const response = await gotScraping({ + url: opts.url, + headers: opts.headers, + timeout: { + request: opts.timeout, + }, + proxyUrl: opts.proxy, + https: { + key: opts.key, + }, + }); + + expect(response.body).to.eql('Hello world!'); + expect(response.statusCode).to.eql(200); + }); + } + ['GET', 'POST', 'PUT', 'DELETE'].forEach((method) => { _it(`handles simple ${method} request`, () => { const opts = getRequestOpts('/hello-world'); @@ -504,7 +667,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. @@ -512,7 +678,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]); const skipInvalidHeaderValue = nodeMajorVersion >= 12; const opts = getRequestOpts(`/get-non-standard-headers?skipInvalidHeaderValue=${skipInvalidHeaderValue ? '1' : '0'}`); @@ -546,8 +711,8 @@ const createTestSuite = ({ return requestPromised(opts) .then((response) => { if (useMainProxy) { - expect(response.statusCode).to.eql(500); - expect(response.body).to.match(/with an invalid HTTP status code/); + expect(response.statusCode).to.eql(592); + expect(response.body).to.eql('Bad status!'); } else { expect(response.statusCode).to.eql(55); expect(response.body).to.eql('Bad status!'); @@ -566,16 +731,11 @@ const createTestSuite = ({ expect(response.statusCode).to.eql(200); expect(response.headers).to.be.an('object'); - // The server returns two headers with same names: - // ... 'Repeating-Header', 'HeaderValue1' ... 'Repeating-Header', 'HeaderValue2' ... - // All headers should be present - const firstIndex = response.rawHeaders.indexOf('Repeating-Header'); - expect(response.rawHeaders[firstIndex + 1]).to.eql('HeaderValue1'); - const secondIndex = response.rawHeaders.indexOf('Repeating-Header', firstIndex + 1); - expect(response.rawHeaders[secondIndex + 1]).to.eql('HeaderValue2'); + expect(response.headers['repeating-header']).to.eql('HeaderValue1, HeaderValue2'); }); }); + // TODO: investigate https case. if (!useSsl) { _it('handles double Host header', () => { // This is a regression test, duplication of Host headers caused the proxy to throw @@ -586,9 +746,9 @@ const createTestSuite = ({ let httpMsg; if (useMainProxy) { port = mainProxyServerPort; - httpMsg = `GET http://localhost:${targetServerPort}/echo-raw-headers HTTP/1.1\r\n` + - 'Host: dummy1.example.com\r\n' + - 'Host: dummy2.example.com\r\n'; + httpMsg = `GET http://localhost:${targetServerPort}/echo-raw-headers HTTP/1.1\r\n` + + 'Host: dummy1.example.com\r\n' + + 'Host: dummy2.example.com\r\n'; if (mainProxyAuth) { const auth = Buffer.from(`${mainProxyAuth.username}:${mainProxyAuth.password}`).toString('base64'); httpMsg += `Proxy-Authorization: Basic ${auth}\r\n`; @@ -596,15 +756,26 @@ const createTestSuite = ({ httpMsg += '\r\n'; } else { port = targetServerPort; - httpMsg = 'GET /echo-raw-headers HTTP/1.1\r\n' + - 'Host: dummy1.example.com\r\n' + - 'Host: dummy2.example.com\r\n\r\n'; + httpMsg = 'GET /echo-raw-headers HTTP/1.1\r\n' + + 'Host: dummy1.example.com\r\n' + + 'Host: dummy2.example.com\r\n\r\n'; + } + + let client; + if (mainProxyServerType === 'https') { + client = tls.connect({ + port, + host: 'localhost', + rejectUnauthorized: false, + }, () => { + client.write(httpMsg); + }); + } else { + client = net.createConnection({ port }, () => { + client.write(httpMsg); + }); } - const client = net.createConnection({ port }, () => { - // console.log('connected to server! sending msg: ' + httpMsg); - client.write(httpMsg); - }); client.on('data', (data) => { // console.log('received data: ' + data.toString()); try { @@ -620,7 +791,6 @@ const createTestSuite = ({ }); client.on('error', reject); }); - }); } @@ -680,7 +850,7 @@ const createTestSuite = ({ return 0; }); const lastConnectionId = sortedIds[sortedIds.length - 1]; - const stats = mainProxyServer.getConnectionStats(lastConnectionId) + const stats = mainProxyServer.getConnectionStats(Number(lastConnectionId)) || mainProxyServerConnectionId2Stats[lastConnectionId]; // 5% range because network negotiation adds to network trafic @@ -737,21 +907,23 @@ const createTestSuite = ({ }); }); - // NOTE: PhantomJS cannot handle proxy auth with empty user or password, both need to be present! - if (!mainProxyAuth || (mainProxyAuth.username && mainProxyAuth.password)) { - _it('handles GET request from PhantomJS', async () => { - // NOTE: use other hostname than 'localhost' or '127.0.0.1' otherwise PhantomJS would skip the proxy! - const phantomUrl = `${useSsl ? 'https' : 'http'}://${LOCALHOST_TEST}:${targetServerPort}/hello-world`; - const response = await phantomGet(phantomUrl, mainProxyUrl); + // Skip on Node 14: HTTPS proxy with upstream proxy causes EPIPE errors. + const isNode14 = process.versions.node.split('.')[0] === '14'; + const skipPuppeteerOnNode14 = isNode14 && mainProxyServerType === 'https' && useUpstreamProxy && !mainProxyAuth; + + if ((!mainProxyAuth || (mainProxyAuth.username && mainProxyAuth.password)) && !skipPuppeteerOnNode14) { + it('handles GET request using puppeteer', async () => { + const targetUrl = `${useSsl ? 'https' : 'http'}://${LOCALHOST_TEST}:${targetServerPort}/hello-world`; + const response = await puppeteerGet(targetUrl, mainProxyUrl); expect(response).to.contain('Hello world!'); }); } if (!useSsl && mainProxyAuth && mainProxyAuth.username && mainProxyAuth.password) { - it('handles GET request from PhantomJS with invalid credentials', async () => { - // NOTE: use other hostname than 'localhost' or '127.0.0.1' otherwise PhantomJS would skip the proxy! - const phantomUrl = `${useSsl ? 'https' : 'http'}://${LOCALHOST_TEST}:${targetServerPort}/hello-world`; - const response = await phantomGet(phantomUrl, `http://bad:password@127.0.0.1:${mainProxyServerPort}`); + it('handles GET request using puppeteer with invalid credentials', async () => { + const targetUrl = `${useSsl ? 'https' : 'http'}://${LOCALHOST_TEST}:${targetServerPort}/hello-world`; + const proxySchema = mainProxyServerType === 'https' ? 'https' : 'http'; + const response = await puppeteerGet(targetUrl, `${proxySchema}://bad:password@127.0.0.1:${mainProxyServerPort}`); expect(response).to.contain('Proxy credentials required'); }); } @@ -769,10 +941,16 @@ const createTestSuite = ({ if (mainProxyAuth && mainProxyAuth.username) { it('handles GET request from curl with invalid credentials', async () => { const curlUrl = `${useSsl ? 'https' : 'http'}://${LOCALHOST_TEST}:${targetServerPort}/hello-world`; + const proxySchema = mainProxyServerType === 'https' ? 'https' : 'http'; // For SSL, we need to return curl's stderr to check what kind of error was there - const output = await curlGet(curlUrl, `http://bad:password@127.0.0.1:${mainProxyServerPort}`, !useSsl); + const output = await curlGet(curlUrl, `${proxySchema}://bad:password@127.0.0.1:${mainProxyServerPort}`, !useSsl); if (useSsl) { - expect(output).to.contain('Received HTTP code 407 from proxy after CONNECT'); + expect(output).to.contain.oneOf([ + // Old error message before dafdb20a26d0c890e83dea61a104b75408481ebd + 'Received HTTP code 407 from proxy after CONNECT', + // and that after + 'CONNECT tunnel failed, response 407', + ]); } else { expect(output).to.contain('Proxy credentials required'); } @@ -786,7 +964,7 @@ const createTestSuite = ({ proxy: { origin: mainProxyUrl, tls: useSsl ? { cert: sslCrt } : null, - } + }, }); ws.on('error', (err) => { @@ -811,6 +989,54 @@ const createTestSuite = ({ }); if (useMainProxy) { + if (!useUpstreamProxy) { + _it(`handles malformed response`, async () => { + const server = net.createServer((socket) => { + socket.end(`HTTP/1.1 x \r\n\r\n`); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + + server.listen(0, () => { + server.off('error', reject); + resolve(); + }); + }); + + const opts = getRequestOpts(`http://127.0.0.1:${server.address().port}`); + return requestPromised(opts) + .then((response) => { + expect(response.statusCode).to.eql(599); + server.close(); + }); + }); + } + + it('handles invalid CONNECT path', async () => { + const requestModule = mainProxyServerType === 'https' ? https : http; + const req = requestModule.request(mainProxyUrl, { + method: 'CONNECT', + path: ':443', + headers: { + host: ':443', + }, + // Accept self-signed certificates for HTTPS proxy. + rejectUnauthorized: false, + }); + + const response = await new Promise((resolve, reject) => { + req.once('connect', (res, socket) => { + socket.destroy(); + resolve(res); + }); + req.once('error', reject); + req.end(); + }); + + expect(response.statusCode).to.equal(400); + }); + _it('returns 404 for non-existent hostname', () => { const opts = getRequestOpts(`http://${NON_EXISTENT_HOSTNAME}`); return requestPromised(opts) @@ -830,42 +1056,89 @@ const createTestSuite = ({ _it('removes hop-by-hop headers (HTTP-only) and leaves other ones', () => { const opts = getRequestOpts('/echo-request-info'); opts.headers['X-Test-Header'] = 'my-test-value'; - opts.headers['TE'] = 'MyTest'; + opts.headers.TE = 'MyTest'; return requestPromised(opts) .then((response) => { expect(response.statusCode).to.eql(200); expect(response.headers['content-type']).to.eql('application/json'); const req = JSON.parse(response.body); expect(req.headers['x-test-header']).to.eql('my-test-value'); - expect(req.headers['te']).to.eql(useSsl ? 'MyTest' : undefined); + expect(req.headers.te).to.eql(useSsl ? 'MyTest' : undefined); }); }); if (mainProxyAuth) { + it('implies username if colon missing', async () => { + const server = net.createServer((socket) => { + socket.end(); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, resolve); + }); + + try { + const proxyUrl = new URL(mainProxyUrl); + const requestModule = proxyUrl.protocol === 'https:' ? https : http; + + const requestOpts = { + hostname: proxyUrl.hostname, + port: proxyUrl.port, + method: 'CONNECT', + path: `127.0.0.1:${server.address().port}`, + headers: { + host: `127.0.0.1:${server.address().port}`, + 'proxy-authorization': `Basic ${Buffer.from('nopassword').toString('base64')}`, + }, + }; + + // Accept self-signed certificates for HTTPS prpxy. + if (proxyUrl.protocol === 'https:') { + requestOpts.rejectUnauthorized = false; + } + + const req = requestModule.request(requestOpts); + const { response, socket, head } = await new Promise((resolve, reject) => { + req.once('connect', (res, sock, h) => resolve({ response: res, socket: sock, head: h })); + req.once('error', reject); + req.end(); + }); + + expect(response.statusCode).to.equal(200); + expect(head.length).to.equal(0); + socket.destroy(); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + it('returns 407 for invalid credentials', () => { + const proxySchema = mainProxyServerType === 'https' ? 'https' : 'http'; + return Promise.resolve() .then(() => { // Test no username and password const opts = getRequestOpts('/whatever'); - opts.proxy = `http://127.0.0.1:${mainProxyServerPort}`; + opts.proxy = `${proxySchema}://127.0.0.1:${mainProxyServerPort}`; return testForErrorResponse(opts, 407); }) .then(() => { // Test good username and invalid password const opts = getRequestOpts('/whatever'); - opts.proxy = `http://${mainProxyAuth.username}:bad-password@127.0.0.1:${mainProxyServerPort}`; + opts.proxy = `${proxySchema}://${mainProxyAuth.username}:bad-password@127.0.0.1:${mainProxyServerPort}`; return testForErrorResponse(opts, 407); }) .then(() => { // Test invalid username and good password const opts = getRequestOpts('/whatever'); - opts.proxy = `http://bad-username:${mainProxyAuth.password}@127.0.0.1:${mainProxyServerPort}`; + opts.proxy = `${proxySchema}://bad-username:${mainProxyAuth.password}@127.0.0.1:${mainProxyServerPort}`; return testForErrorResponse(opts, 407); }) .then(() => { - // Test invalid username and good password + // Test invalid username and bad password const opts = getRequestOpts('/whatever'); - opts.proxy = `http://bad-username:bad-password@127.0.0.1:${mainProxyServerPort}`; + opts.proxy = `${proxySchema}://bad-username:bad-password@127.0.0.1:${mainProxyServerPort}`; return testForErrorResponse(opts, 407); }) .then((response) => { @@ -878,22 +1151,22 @@ const createTestSuite = ({ it('returns 500 on error in prepareRequestFunction', () => { return Promise.resolve() - .then(() => { - const opts = getRequestOpts(`${useSsl ? 'https' : 'http'}://activate-error-in-prep-req-func-throw.gov`); - return testForErrorResponse(opts, 500); - }) - .then(() => { - const opts = getRequestOpts(`${useSsl ? 'https' : 'http'}://activate-error-in-prep-req-func-promise.gov`); - return testForErrorResponse(opts, 500); - }) - .then(() => { - const opts = getRequestOpts(`${useSsl ? 'https' : 'http'}://activate-error-in-prep-req-func-throw-known.gov`); - return testForErrorResponse(opts, 501); - }) - .then(() => { - const opts = getRequestOpts(`${useSsl ? 'https' : 'http'}://activate-error-in-prep-req-func-promise-known.gov`); - return testForErrorResponse(opts, 501); - }); + .then(() => { + const opts = getRequestOpts(`${useSsl ? 'https' : 'http'}://activate-error-in-prep-req-func-throw.gov`); + return testForErrorResponse(opts, 500); + }) + .then(() => { + const opts = getRequestOpts(`${useSsl ? 'https' : 'http'}://activate-error-in-prep-req-func-promise.gov`); + return testForErrorResponse(opts, 500); + }) + .then(() => { + const opts = getRequestOpts(`${useSsl ? 'https' : 'http'}://activate-error-in-prep-req-func-throw-known.gov`); + return testForErrorResponse(opts, 501); + }) + .then(() => { + const opts = getRequestOpts(`${useSsl ? 'https' : 'http'}://activate-error-in-prep-req-func-promise-known.gov`); + return testForErrorResponse(opts, 501); + }); }); } @@ -908,26 +1181,49 @@ const createTestSuite = ({ return testForErrorResponse(opts, 500); }); - it('fails gracefully on invalid upstream proxy username', () => { + it('fails gracefully on invalid upstream proxy username', async () => { const opts = getRequestOpts(`${useSsl ? 'https' : 'http'}://activate-invalid-upstream-proxy-username`); - return testForErrorResponse(opts, 500); + + if (useSsl) { + try { + await requestPromised(opts); + expect(false).to.be.eql(true); + } catch (error) { + expect(error.message).to.be.eql('tunneling socket could not be established, statusCode=597'); + } + } else { + const response = await requestPromised(opts); + + expect(response.statusCode).to.be.eql(597); + expect(response.body).to.be.eql('Invalid colon in username in upstream proxy credentials'); + } }); it('fails gracefully on non-existent upstream proxy host', () => { const opts = getRequestOpts(`${useSsl ? 'https' : 'http'}://activate-unknown-upstream-proxy-host.gov`); - return testForErrorResponse(opts, 502); + return testForErrorResponse(opts, 593); }); if (upstreamProxyAuth) { _it('fails gracefully on bad upstream proxy credentials', () => { const opts = getRequestOpts(`${useSsl ? 'https' : 'http'}://activate-bad-upstream-proxy-credentials.gov`); - return testForErrorResponse(opts, 502); + return testForErrorResponse(opts, 597); }); } } if (testCustomResponse) { if (!useSsl) { + it('supports custom response - buffer', () => { + const opts = getRequestOpts('http://test-custom-response-buffer.gov'); + opts.gzip = true; + return requestPromised(opts) + .then((response) => { + expect(response.statusCode).to.eql(200); + expect(response.body).to.eql('Hello, world!'); + }); + }); + it('supports custom response - simple', () => { const opts = getRequestOpts('http://test-custom-response-simple.gov/some/path'); return requestPromised(opts) @@ -983,47 +1279,43 @@ const createTestSuite = ({ } } - after(function () { + after(async function () { this.timeout(3 * 1000); - return wait(1000) - .then(() => { - // Ensure all handlers are removed - if (mainProxyServer) { - expect(mainProxyServer.getConnectionIds()).to.be.deep.eql([]); - } - expect(mainProxyServerConnectionIds).to.be.deep.eql([]); + await wait(1000); - const closedSomeConnectionsTwice = mainProxyServerConnectionsClosed - .reduce((duplicateConnections, id, index) => { - if (index > 0 && mainProxyServerConnectionsClosed[index - 1] === id) { - duplicateConnections.push(id); - } - return duplicateConnections; - }, []); + // Ensure all handlers are removed + if (mainProxyServer) { + expect(mainProxyServer.getConnectionIds()).to.be.deep.eql([]); + } + expect(mainProxyServerConnectionIds).to.be.deep.eql([]); - expect(closedSomeConnectionsTwice).to.be.deep.eql([]); - if (mainProxyServerStatisticsInterval) clearInterval(mainProxyServerStatisticsInterval); - if (mainProxyServer) { - // NOTE: we need to forcibly close pending connections, - // because e.g. on 502 errors in HTTPS mode, the request library - // doesn't close the connection and this would timeout - return mainProxyServer.close(true); + const closedSomeConnectionsTwice = mainProxyServerConnectionsClosed + .reduce((duplicateConnections, id, index) => { + if (index > 0 && mainProxyServerConnectionsClosed[index - 1] === id) { + duplicateConnections.push(id); } - }) - .then(() => { - if (upstreamProxyServer) { - // NOTE: We used to wait for upstream proxy connections to close, - // but for HTTPS, in Node 10+, they linger for some reason... - // return util.promisify(upstreamProxyServer.close).bind(upstreamProxyServer)(); - upstreamProxyServer.close(); + return duplicateConnections; + }, []); - } - }) - .then(() => { - if (targetServer) { - return targetServer.close(); - } - }); + expect(closedSomeConnectionsTwice).to.be.deep.eql([]); + if (mainProxyServerStatisticsInterval) clearInterval(mainProxyServerStatisticsInterval); + + if (mainProxyServer) { + // NOTE: we need to forcibly close pending connections, + // because e.g. on 502 errors in HTTPS mode, the request library + // doesn't close the connection and this would timeout + await mainProxyServer.close(true); + } + + if (upstreamProxyServer) { + // NOTE: We used to wait for upstream proxy connections to close, + // but for HTTPS, in Node 10+, they linger for some reason... + upstreamProxyServer.close(); + } + + if (targetServer) { + await targetServer.close(); + } }); }; }; @@ -1034,10 +1326,11 @@ describe('Test 0 port option', async () => { const server = new Server({ port: 0, }); - // eslint-disable-next-line no-await-in-loop + /* eslint-disable no-await-in-loop */ await server.listen(); expect(server.port).to.be.eql(server.server.address().port); - server.close(true); + await server.close(true); + /* eslint-enable no-await-in-loop */ } }); }); @@ -1063,7 +1356,331 @@ describe('Server (HTTPS -> Target)', createTestSuite({ useMainProxy: false, })); +describe('non-200 upstream connect response', () => { + // No assertion planning :( + // https://github.com/chaijs/chai/issues/670 + let success = false; + + after(() => { + if (!success) { + throw new Error('Failed'); + } + }); + + 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'); + }); + await new Promise((resolve) => server.listen(resolve)); + const serverPort = server.address().port; + const proxyServer = new 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', + }, + }); + 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', async () => { + await proxyServer.close(); + await new Promise((res) => server.close(res)); + resolve(); + }); + }); + + req.end(); + }); + }); +}); + +it('supports localAddress', async () => { + const target = http.createServer((serverRequest, serverResponse) => { + serverResponse.end(serverRequest.socket.remoteAddress); + }); + + await util.promisify(target.listen.bind(target))(0); + + const server = new Server({ + port: 0, + prepareRequestFunction: () => { + return { + localAddress: '127.0.0.2', + }; + }, + }); + + await server.listen(); + + const response = await requestPromised({ + url: `http://127.0.0.1:${target.address().port}`, + proxy: `http://127.0.0.2:${server.port}`, + }); + + try { + expect(response.body).to.be.equal('::ffff:127.0.0.2'); + } finally { + await server.close(); + await util.promisify(target.close.bind(target))(); + } +}); + +it('supports https proxy relay', async () => { + const target = https.createServer(() => { + }); + target.listen(() => { + }); + + const proxyServer = new Server({ + port: 6666, + prepareRequestFunction: () => { + console.log(`https://localhost:${target.address().port}`); + return { + upstreamProxyUrl: `https://localhost:${target.address().port}`, + }; + }, + }); + let proxyServerError = false; + proxyServer.on('requestFailed', () => { + // requestFailed will be called if we pass an invalid proxy url + proxyServerError = true; + }) + + await proxyServer.listen(); + + try { + await requestPromised({ + url: 'https://www.google.com', + proxy: 'http://localhost:6666', + strictSSL: false, + }); + } catch (e) { + // the request will fail with the following error: + // Error: tunneling socket could not be established, statusCode=599 + } + expect(proxyServerError).to.be.equal(false); + + await proxyServer.close(); + await util.promisify(target.close.bind(target))(); +}); + +it('supports custom CONNECT server handler', async () => { + const server = new Server({ + port: 0, + prepareRequestFunction: () => { + const customConnectServer = http.createServer((_request, response) => { + response.end('Hello, world!'); + }); + + return { + customConnectServer, + }; + }, + }); + + await server.listen(); + + try { + const response = await new Promise((resolve, reject) => { + http.request(`http://127.0.0.1:${server.port}`, { + method: 'CONNECT', + path: 'example.com:80', + headers: { + host: 'example.com:80', + }, + }).on('connect', (connectResponse, socket, head) => { + http.request('http://example.com', { + createConnection: () => socket, + }, (res) => { + const buffer = []; + + res.on('data', (chunk) => { + buffer.push(chunk); + }); + + res.on('end', () => { + resolve(Buffer.concat(buffer).toString()); + }); + }).on('error', reject).end(); + }).on('error', reject).end(); + }); + + expect(response).to.be.equal('Hello, world!'); + } finally { + await server.close(); + } +}); + +it('supports pre-response CONNECT payload', async () => { + const plain = net.createServer((socket) => { + socket.pipe(socket); + }); + + await new Promise((resolve, reject) => { + plain.once('error', reject); + plain.listen(0, resolve); + }); + + const server = new Server({ port: 0 }); + await server.listen(); + + try { + const socket = net.connect({ + host: '127.0.0.1', + port: server.port, + }); + + socket.write([ + `CONNECT 127.0.0.1:${plain.address().port} HTTP/1.1`, + `Host: 127.0.0.1:${plain.address().port}`, + ``, + `foobar`, + ].join('\r\n')); + + const success = await new Promise((resolve, reject) => { + let received = false; + + socket.once('error', reject); + socket.on('data', (data) => { + received = data.includes('foobar'); + socket.end(); + }); + + socket.setTimeout(1000, () => { + socket.destroy(new Error('Socket timed out')); + }); + + socket.once('close', () => resolve(received)); + }); + + if (!success) throw new Error('failure'); + } finally { + await server.close(); + await new Promise((resolve) => plain.close(resolve)); + } +}); + +describe('supports ignoreUpstreamProxyCertificate', () => { + const serverOptions = { + key: sslKey, + cert: sslCrt, + }; + + const responseMessage = 'Hello World!'; + + it('fails on upstream error', async () => { + const target = https.createServer(serverOptions, (_req, res) => { + res.write(responseMessage); + res.end(); + }); + + await util.promisify(target.listen.bind(target))(0); + + const proxyServer = new Server({ + port: 6666, + prepareRequestFunction: () => { + return { + upstreamProxyUrl: `https://localhost:${target.address().port}`, + }; + }, + }); + + let proxyServerError = false; + proxyServer.on('requestFailed', () => { + // requestFailed will be called if we pass an invalid proxy url + proxyServerError = true; + }); + + await proxyServer.listen(); + + /** + * request is sent with rejectUnauthorized: true + * so when the SSL certificate is not trusted (self-signed, expired, invalid), client will reject the connection + */ + const response = await requestPromised({ + proxy: 'http://localhost:6666', + url: 'http://httpbin.org/ip', + }); + + expect(proxyServerError).to.be.equal(false); + + expect(response.statusCode).to.be.equal(599); + + await proxyServer.close(); + await util.promisify(target.close.bind(target))(); + }); + + it('bypass upstream error', async () => { + const target = https.createServer(serverOptions, (_req, res) => { + res.write(responseMessage); + res.end(); + }); + + await util.promisify(target.listen.bind(target))(0); + + const proxyServer = new Server({ + port: 6666, + prepareRequestFunction: () => { + return { + ignoreUpstreamProxyCertificate: true, + upstreamProxyUrl: `https://localhost:${target.address().port}`, + }; + }, + }); + + let proxyServerError = false; + proxyServer.on('requestFailed', () => { + // requestFailed will be called if we pass an invalid proxy url + proxyServerError = true; + }); + + await proxyServer.listen(); + + /** + * request is sent with rejectUnauthorized: false + * so when the SSL certificate is not trusted (self-signed, expired, invalid), client won't reject the connection + */ + const response = await requestPromised({ + proxy: 'http://localhost:6666', + url: 'http://httpbin.org/ip', + }); + + expect(proxyServerError).to.be.equal(false); + + expect(response.statusCode).to.be.equal(200); + expect(response.body).to.be.equal(responseMessage); + + await proxyServer.close(); + await util.promisify(target.close.bind(target))(); + }); +}); + // Run all combinations of test parameters +const mainProxyServerTypeVariants = [ + 'http', + 'https', +]; + const useSslVariants = [ false, true, @@ -1086,50 +1703,164 @@ const upstreamProxyAuthVariants = [ { type: 'Basic', username: 'us%erB', password: 'p$as%sA' }, ]; -useSslVariants.forEach((useSsl) => { - mainProxyAuthVariants.forEach((mainProxyAuth) => { +mainProxyServerTypeVariants.forEach((mainProxyServerType) => { + useSslVariants.forEach((useSsl) => { + mainProxyAuthVariants.forEach((mainProxyAuth) => { + const proxyTypeLabel = mainProxyServerType === 'https' ? 'HTTPS' : 'HTTP'; + const baseDesc = `Server (${useSsl ? 'HTTPS' : 'HTTP'} -> ${proxyTypeLabel} Main proxy`; + + // Test custom response separately (it doesn't use upstream proxies) + describe(`${baseDesc} -> Target + custom responses)`, createTestSuite({ + useMainProxy: true, + useSsl, + mainProxyAuth, + mainProxyServerType, + testCustomResponse: true, + })); + + useUpstreamProxyVariants.forEach((useUpstreamProxy) => { + // If useUpstreamProxy is not used, only try one variant of upstreamProxyAuth + let variants = upstreamProxyAuthVariants; + if (!useUpstreamProxy) variants = [null]; + + variants.forEach((upstreamProxyAuth) => { + let desc = `${baseDesc} `; + + if (mainProxyAuth) { + if (!mainProxyAuth) desc += 'public '; + else if (mainProxyAuth.username && mainProxyAuth.password) desc += 'with username:password '; + else if (mainProxyAuth.username) desc += 'with username only '; + else desc += 'with password only '; + } + if (useUpstreamProxy) { + desc += '-> Upstream proxy '; + if (!upstreamProxyAuth) desc += 'public '; + else if (upstreamProxyAuth.username && upstreamProxyAuth.password) desc += 'with username:password '; + else if (upstreamProxyAuth.username) desc += 'with username only '; + else desc += 'with password only '; + } + desc += '-> Target)'; + + describe(desc, createTestSuite({ + useMainProxy: true, + useSsl, + useUpstreamProxy, + mainProxyAuth, + mainProxyServerType, + upstreamProxyAuth, + })); + }); + }); + }); + }); +}); - const baseDesc = `Server (${useSsl ? 'HTTPS' : 'HTTP'} -> Main proxy`; +describe('Socket error handler regression test', () => { + let server; + let logs = []; + const originalLog = console.log; - // Test custom response separately (it doesn't use upstream proxies) - describe(`${baseDesc} -> Target + custom responses)`, createTestSuite({ - useMainProxy: true, - useSsl, - mainProxyAuth, - testCustomResponse: true, - })); + before(() => { + console.log = (...args) => { + logs.push(args.join(' ')); + originalLog.apply(console, args); + }; + }); - useUpstreamProxyVariants.forEach((useUpstreamProxy) => { - // If useUpstreamProxy is not used, only try one variant of upstreamProxyAuth - let variants = upstreamProxyAuthVariants; - if (!useUpstreamProxy) variants = [null]; + after(() => { + console.log = originalLog; + }); - variants.forEach((upstreamProxyAuth) => { - let desc = `${baseDesc} `; + beforeEach(() => { + logs = []; + }); - if (mainProxyAuth) { - if (!mainProxyAuth) desc += 'public '; - else if (mainProxyAuth.username && mainProxyAuth.password) desc += 'with username:password '; - else if (mainProxyAuth.username) desc += 'with username only '; - else desc += 'with password only '; - } - if (useUpstreamProxy) { - desc += '-> Upstream proxy '; - if (!upstreamProxyAuth) desc += 'public '; - else if (upstreamProxyAuth.username && upstreamProxyAuth.password) desc += 'with username:password '; - else if (upstreamProxyAuth.username) desc += 'with username only '; - else desc += 'with password only '; - } - desc += '-> Target)'; - - describe(desc, createTestSuite({ - useMainProxy: true, - useSsl, - useUpstreamProxy, - mainProxyAuth, - upstreamProxyAuth, - })); + afterEach(async () => { + if (server) { + await server.close(true); + server = null; + } + }); + + // The bug was checking `this.listenerCount('error')` (Server) instead of `socket.listenerCount('error')`. + // By adding an error listener to the Server, we make server.listenerCount('error') === 1. + // With buggy code: condition becomes TRUE (1 === 1) and incorrectly logs. + // With fixed code: condition stays FALSE (socket has 2 listeners, 2 !== 1) and correctly doesn't log. + it('does not log when server has 1 error listener but socket has multiple', async () => { + server = new Server({ port: 0, verbose: true }); + + server.on('error', () => {}); + + const settled = new Promise((resolve, reject) => { + server.server.once('connection', (serverSocket) => { + setImmediate(() => { + try { + expect(server.listenerCount('error')).to.equal(1); + expect(serverSocket.listenerCount('error')).to.equal(2); + + serverSocket.emit('error', new Error('Regression test error')); + + setTimeout(() => { + try { + const hasLog = logs.some((log) => log.includes('Source socket emitted error') && log.includes('Regression test error')); + expect(hasLog).to.equal(false, 'Should check socket.listenerCount, not this.listenerCount (server)'); + + serverSocket.destroy(); + resolve(); + } catch (err) { + reject(err); + } + }, 50); + } catch (err) { + reject(err); + } + }); }); }); + + await server.listen(); + net.connect(server.port, '127.0.0.1'); + + await settled; }); }); + +describe('Server constructor', () => { + it('should default to "http" when serverType is not specified', async () => { + const server = new Server({ port: 0 }); + await server.listen(); + expect(server.serverType).to.equal('http'); + expect(server.server).to.be.instanceOf(http.Server); + await server.close(true); + }); + + it('should use "http" when explicitly specified', async () => { + const server = new Server({ port: 0, serverType: 'http' }); + await server.listen(); + expect(server.serverType).to.equal('http'); + expect(server.server).to.be.instanceOf(http.Server); + await server.close(true); + }); + + it('should use "https" when explicitly specified with httpsOptions', async () => { + const server = new Server({ + port: 0, + serverType: 'https', + httpsOptions: { key: sslKey, cert: sslCrt } + }); + await server.listen(); + expect(server.serverType).to.equal('https'); + expect(server.server).to.be.instanceOf(https.Server); + await server.close(true); + }); + + it('requires httpsOptions when serverType is "https"', () => { + expect(() => { + new Server({ + port: 0, + serverType: 'https', + }); + }).to.throw('httpsOptions is required when serverType is "https"'); + }); +}); + diff --git a/test/e2e/socks.js b/test/e2e/socks.js new file mode 100644 index 00000000..bc324ac1 --- /dev/null +++ b/test/e2e/socks.js @@ -0,0 +1,82 @@ +import portastic from 'portastic'; +import socksv5 from 'socksv5'; +import { gotScraping } from 'got-scraping'; +import { expect } from 'chai'; +import * as ProxyChain from '../../src/index.js'; + +describe('SOCKS protocol', () => { + let socksServer; + let proxyServer; + let anonymizeProxyUrl; + + afterEach(async () => { + if (socksServer) socksServer.close(); + if (proxyServer) await proxyServer.close(); + if (anonymizeProxyUrl) await ProxyChain.closeAnonymizedProxy(anonymizeProxyUrl, true); + }); + + 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}`, + }; + }, + }); + 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', 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}`, + }; + }, + }); + 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', async () => { + const ports = await portastic.find({ min: 50500, max: 50750 }); + 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!'); + })); + + anonymizeProxyUrl = await ProxyChain.anonymizeProxy({ + port: proxyPort, + url: `socks://proxy-ch@in:rules!@127.0.0.1:${socksPort}`, + }); + const response = await gotScraping.get({ url: 'https://example.com', proxyUrl: anonymizeProxyUrl }); + expect(response.body).to.contain('Example Domain'); + }).timeout(10 * 1000); +}); diff --git a/test/ssl.crt b/test/e2e/ssl.crt similarity index 100% rename from test/ssl.crt rename to test/e2e/ssl.crt diff --git a/test/ssl.key b/test/e2e/ssl.key similarity index 100% rename from test/ssl.key rename to test/e2e/ssl.key diff --git a/test/e2e/tcp_tunnel.js b/test/e2e/tcp_tunnel.js new file mode 100644 index 00000000..6f38f99e --- /dev/null +++ b/test/e2e/tcp_tunnel.js @@ -0,0 +1,152 @@ +import net from 'node:net'; +import { expect, assert } from 'chai'; +import http from 'node:http'; +import proxy from 'proxy'; + +import { createTunnel, closeTunnel } from '../../src/index.js'; +import { expectThrowsAsync } from '../utils/throws_async.js'; + +const destroySocket = (socket) => new Promise((resolve) => { + if (!socket || socket.destroyed) return resolve(); + socket.once('close', () => resolve()); + socket.destroy(); +}); + +const serverListen = (server, port) => new Promise((resolve, reject) => { + server.once('error', reject); + + server.listen(port, () => { + server.off('error', reject); + + resolve(server.address().port); + }); +}); + +const connect = (port) => new Promise((resolve, reject) => { + const socket = net.connect({ port }, (err) => { + if (err) return reject(err); + return resolve(socket); + }); +}); + +const closeServer = async (server, connections) => { + if (!server || !server.listening) return; + await Promise.all(connections.map(destroySocket)); + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); +}; + +describe('tcp_tunnel.createTunnel', () => { + it('throws error if proxyUrl is not in correct format', () => { + expectThrowsAsync(async () => { await createTunnel('socks://user:password@whatever.com:123', 'localhost:9000'); }, /must have the "http" protocol/); + expectThrowsAsync(async () => { await createTunnel('socks5://user:password@whatever.com', 'localhost:9000'); }, /must have the "http" protocol/); + }); + it('throws error if target is not in correct format', () => { + expectThrowsAsync(async () => { await createTunnel('http://user:password@whatever.com:12'); }, 'Missing target hostname'); + expectThrowsAsync(async () => { await createTunnel('http://user:password@whatever.com:12', null); }, 'Missing target hostname'); + expectThrowsAsync(async () => { await createTunnel('http://user:password@whatever.com:12', ''); }, 'Missing target hostname'); + expectThrowsAsync(async () => { await createTunnel('http://user:password@whatever.com:12', 'whatever'); }, 'Missing target port'); + expectThrowsAsync(async () => { await createTunnel('http://user:password@whatever.com:12', 'whatever:'); }, 'Missing target port'); + expectThrowsAsync(async () => { await createTunnel('http://user:password@whatever.com:12', ':whatever'); }, /Invalid URL/); + }); + it('correctly tunnels to tcp service and then is able to close the connection', () => { + const proxyServerConnections = []; + + const proxyServer = proxy(http.createServer()); + proxyServer.on('connection', (conn) => proxyServerConnections.push(conn)); + + const targetServiceConnections = []; + const targetService = net.createServer(); + targetService.on('connection', (conn) => { + targetServiceConnections.push(conn); + conn.setEncoding('utf8'); + conn.on('data', conn.write); + conn.on('error', (err) => { throw err; }); + }); + + return serverListen(proxyServer, 0) + .then(() => serverListen(targetService, 0)) + .then((targetServicePort) => { + return createTunnel(`http://localhost:${proxyServer.address().port}`, `localhost:${targetServicePort}`); + }) + .then(closeTunnel) + .finally(() => closeServer(proxyServer, proxyServerConnections)) + .finally(() => closeServer(targetService, targetServiceConnections)); + }); + it('correctly tunnels to tcp service and then is able to close the connection (async/await)', async () => { + const proxyServerConnections = []; + + const proxyServer = proxy(http.createServer()); + proxyServer.on('connection', (conn) => proxyServerConnections.push(conn)); + + const targetServiceConnections = []; + const targetService = net.createServer(); + targetService.on('connection', (conn) => { + targetServiceConnections.push(conn); + conn.setEncoding('utf8'); + conn.on('data', conn.write); + conn.on('error', (err) => { throw err; }); + }); + + 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; + let response = ''; + const expected = [ + 'testA', + 'testB', + 'testC', + ]; + + const proxyServerConnections = []; + + const proxyServer = proxy(http.createServer()); + proxyServer.on('connection', (conn) => proxyServerConnections.push(conn)); + + const targetServiceConnections = []; + const targetService = net.createServer(); + targetService.on('connection', (conn) => { + targetServiceConnections.push(conn); + conn.setEncoding('utf8'); + conn.on('data', conn.write); + conn.on('error', (err) => conn.write(JSON.stringify(err))); + }); + + return serverListen(proxyServer, 0) + .then(() => serverListen(targetService, 0)) + .then((targetServicePort) => createTunnel(`http://localhost:${proxyServer.address().port}`, `localhost:${targetServicePort}`)) + .then((newTunnel) => { + tunnel = newTunnel; + + const { port } = new URL(`connect://${newTunnel}`); + + return connect(port); + }) + .then((connection) => { + connection.setEncoding('utf8'); + connection.on('data', (d) => { response += d; }); + expected.forEach((text) => connection.write(`${text}\r\n`)); + return new Promise((resolve) => setTimeout(() => { + connection.end(); + resolve(tunnel); + }, 500)); + }) + .then(() => { + expect(response.trim().split('\r\n')).to.be.deep.eql(expected); + return closeTunnel(tunnel); + }) + .finally(() => closeServer(proxyServer, proxyServerConnections)) + .finally(() => closeServer(targetService, targetServiceConnections)); + }); +}); diff --git a/test/ee-memory-leak.js b/test/ee-memory-leak.js deleted file mode 100644 index b995769c..00000000 --- a/test/ee-memory-leak.js +++ /dev/null @@ -1,59 +0,0 @@ -const net = require('net'); -const http = require('http'); -const { assert } = require('chai'); -const ProxyChain = require('..'); - -describe('ProxyChain server', () => { - let server; - let port; - - before(() => { - server = http.createServer((_request, response) => { - response.end('Hello, world!'); - }).listen(0); - - port = server.address().port; - }); - - after(() => { - server.close(); - }); - - it('does not leak events', (done) => { - const proxyServer = new ProxyChain.Server(); - - let socket; - let registeredCount; - proxyServer.server.prependOnceListener('request', (request) => { - socket = request.socket; - registeredCount = socket.listenerCount('error'); - }); - - const callback = () => { - assert.equal(socket.listenerCount('error'), registeredCount); - done(); - }; - - proxyServer.listen(async () => { - const proxyServerPort = proxyServer.server.address().port; - - const requestCount = 20; - - const client = net.connect({ - host: 'localhost', - port: proxyServerPort, - }); - - client.setTimeout(100); - - client.on('timeout', () => { - client.destroy(); - callback(); - }); - - 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`); - } - }); - }); -}); diff --git a/test/phantom_get.js b/test/phantom_get.js deleted file mode 100644 index a7d6b882..00000000 --- a/test/phantom_get.js +++ /dev/null @@ -1,42 +0,0 @@ -"use strict"; - -// Only run this code in the PhantomJS environment -if (typeof(phantom)==='object') { - var page = require('webpage').create(); - var system = require('system'); - var settings = { - resourceTimeout: 10 * 1000, - }; - - if (system.args.length < 2) { - console.log('Opens a web page and prints its content'); - console.log('Usage: phantomjs phantomjs_get.js URL [--verbose]'); - phantom.exit(1); - } else { - var url = system.args[1]; - var verbose = system.args[2]; - - if (verbose) { - page.onError = function (msg, trace) { - console.log('ERROR: ' + msg); - console.log('TRACE: ' + trace); - }; - page.onResourceError = function (resourceError) { - console.log('RESOURCE ERROR: ' + JSON.stringify(resourceError)); - }; - page.onResourceTimeout = function (response) { - console.log('RESOURCE TIMEOUT: ' + JSON.stringify(response)); - }; - } - - page.open(url, settings, function (status) { - if (status !== 'success') { - console.log('Unable to load ' + url); - phantom.exit(1); - } else { - console.log(page.content); - phantom.exit(0); - } - }); - } -} diff --git a/test/tcp_tunnel.js b/test/tcp_tunnel.js deleted file mode 100644 index 8c4a2968..00000000 --- a/test/tcp_tunnel.js +++ /dev/null @@ -1,177 +0,0 @@ -const _ = require('underscore'); -const net = require('net'); -const { expect, assert } = require('chai'); -const http = require('http'); -const proxy = require('proxy'); - -const { createTunnel, closeTunnel } = require('../build/index'); -const { findFreePort } = require('./tools'); - -const destroySocket = socket => new Promise((resolve, reject) => { - if (!socket || socket.destroyed) return resolve(); - socket.destroy((err) => { - if (err) return reject(err); - return resolve(); - }); -}); - -const serverListen = (server, port) => new Promise((resolve, reject) => { - server.listen(port, (err) => { - if (err) return reject(err); - return resolve(port); - }); -}); - -const connect = port => new Promise((resolve, reject) => { - const socket = net.connect({ port }, (err) => { - if (err) return reject(err); - return resolve(socket); - }); -}); - -const closeServer = (server, connections) => new Promise((resolve, reject) => { - if (!server || !server.listening) return resolve(); - Promise.all(connections, destroySocket).then(() => { - server.close((err) => { - if (err) return reject(err); - return resolve(); - }); - }); -}); - -let targetService; -const targetServiceConnections = []; -let proxyServer; -const proxyServerConnections = []; -let localConnection; - -after(function () { - this.timeout(10 * 1000); - return closeServer(proxyServer, proxyServerConnections) - .then(() => closeServer(targetService, targetServiceConnections)) - .then(() => destroySocket(localConnection)); -}); - -describe('tcp_tunnel.createTunnel', () => { - it('throws error if proxyUrl is not in correct format', () => { - assert.throws(() => { createTunnel('socks://user:password@whatever.com:123', 'localhost:9000'); }, /must have the "http" protocol/); - assert.throws(() => { createTunnel('socks5://user:password@whatever.com', 'localhost:9000'); }, /must contain hostname and port/); - assert.throws(() => { createTunnel('http://us%3Aer:password@whatever.com:345', 'localhost:9000'); }, /cannot contain the colon/); - }); - it('throws error if target is not in correct format', () => { - assert.throws(() => { createTunnel('http://user:password@whatever.com:12'); }, /target host needs to include both/); - assert.throws(() => { createTunnel('http://user:password@whatever.com:12', null); }, /target host needs to include both/); - assert.throws(() => { createTunnel('http://user:password@whatever.com:12', ''); }, /target host needs to include both/); - assert.throws(() => { createTunnel('http://user:password@whatever.com:12', 'whatever'); }, /target host needs to include both/); - assert.throws(() => { createTunnel('http://user:password@whatever.com:12', 'whatever:'); }, /target host needs to include both/); - assert.throws(() => { createTunnel('http://user:password@whatever.com:12', ':whatever'); }, /target host needs to include both/); - }); - it('correctly tunnels to tcp service and then is able to close the connection', () => { - let proxyPort; - let servicePort; - return findFreePort() - .then((port) => { - proxyServer = proxy(http.createServer()); - proxyPort = port; - return serverListen(proxyServer, proxyPort); - }) - .then(() => findFreePort()) - .then((port) => { - targetService = net.createServer(); - servicePort = port; - targetService.on('connection', (conn) => { - conn.setEncoding('utf8'); - conn.on('data', conn.write); - conn.on('error', (err) => { throw err; }); - }); - return serverListen(targetService, servicePort); - }) - .then(() => { - return createTunnel(`http://localhost:${proxyPort}`, `localhost:${servicePort}`); - }) - .then(closeTunnel); - }); - it('correctly tunnels to tcp service and then is able to close the connection when used with callbacks', () => { - let proxyPort; - let servicePort; - return findFreePort() - .then((port) => { - proxyServer = proxy(http.createServer()); - proxyPort = port; - return serverListen(proxyServer, proxyPort); - }) - .then(() => findFreePort()) - .then((port) => { - targetService = net.createServer(); - servicePort = port; - targetService.on('connection', (conn) => { - conn.setEncoding('utf8'); - conn.on('data', conn.write); - conn.on('error', (err) => { throw err; }); - }); - return serverListen(targetService, servicePort); - }) - .then(() => new Promise((resolve, reject) => { - createTunnel(`http://localhost:${proxyPort}`, `localhost:${servicePort}`, {}, (err, tunnel) => { - if (err) return reject(err); - return resolve(tunnel); - }); - }) - .then(tunnel => new Promise((resolve, reject) => { - closeTunnel(tunnel, true, (err, closed) => { - if (err) return reject(err); - return resolve(closed); - }); - }))); - }); - it('creates tunnel that is able to transfer data', () => { - let proxyPort; - let servicePort; - let tunnel; - let response = ''; - const expected = [ - 'testA', - 'testB', - 'testC', - ]; - return findFreePort() - .then((port) => { - proxyServer = proxy(http.createServer()); - proxyServer.on('connection', conn => proxyServerConnections.push(conn)); - proxyPort = port; - return serverListen(proxyServer, proxyPort); - }) - .then(() => findFreePort()) - .then((port) => { - targetService = net.createServer(); - servicePort = port; - targetService.on('connection', (conn) => { - targetServiceConnections.push(conn); - conn.setEncoding('utf8'); - conn.on('data', conn.write); - conn.on('error', err => conn.write(JSON.stringify(err))); - }); - return serverListen(targetService, servicePort); - }) - .then(() => createTunnel(`http://localhost:${proxyPort}`, `localhost:${servicePort}`)) - .then((newTunnel) => { - tunnel = newTunnel; - const [hostname, port] = tunnel.split(':'); // eslint-disable-line - return connect(port); - }) - .then((connection) => { - localConnection = connection; - connection.setEncoding('utf8'); - connection.on('data', (d) => { response += d; }); - expected.forEach(text => connection.write(`${text}\r\n`)); - return new Promise(resolve => setTimeout(() => { - connection.end(); - resolve(tunnel); - }, 500)); - }) - .then(() => { - expect(response.trim().split('\r\n')).to.be.deep.eql(expected); - return closeTunnel(tunnel); - }); - }); -}); diff --git a/test/tools.js b/test/tools.js deleted file mode 100644 index b0c734b3..00000000 --- a/test/tools.js +++ /dev/null @@ -1,503 +0,0 @@ -const { expect } = require('chai'); -const net = require('net'); -const portastic = require('portastic'); -const { - parseUrl, redactUrl, parseHostHeader, isHopByHopHeader, isInvalidHeader, - parseProxyAuthorizationHeader, addHeader, - nodeify, maybeAddProxyAuthorizationHeader, -} = require('../build/tools'); - -/* global describe, it */ - -const PORT_SELECTION_CONFIG = { - FROM: 20000, - TO: 60000, - RETRY_COUNT: 10, -}; - -const findFreePort = () => { - // Let 'min' be a random value in the first half of the PORT_FROM-PORT_TO range, - // to reduce a chance of collision if other ProxyChain is started at the same time. - const half = Math.floor((PORT_SELECTION_CONFIG.TO - PORT_SELECTION_CONFIG.FROM) / 2); - - const opts = { - min: PORT_SELECTION_CONFIG.FROM + Math.floor(Math.random() * half), - max: PORT_SELECTION_CONFIG.TO, - retrieve: 1, - }; - - return portastic.find(opts) - .then((ports) => { - if (ports.length < 1) throw new Error(`There are no more free ports in range from ${PORT_SELECTION_CONFIG.FROM} to ${PORT_SELECTION_CONFIG.TO}`); // eslint-disable-line max-len - return ports[0]; - }); -}; - -const testUrl = (url, expected) => { - const parsed1 = parseUrl(url); - expect(parsed1).to.contain(expected); -}; - -describe('tools.parseUrl()', () => { - it('works', () => { - testUrl('https://username:password@www.example.COM:12345/some/path', { - auth: 'username:password', - protocol: 'https:', - scheme: 'https', - username: 'username', - password: 'password', - host: 'www.example.com:12345', - hostname: 'www.example.com', - port: 12345, - }); - - testUrl('https://username:password@www.example.com/some/path', { - auth: 'username:password', - protocol: 'https:', - scheme: 'https', - username: 'username', - password: 'password', - host: 'www.example.com', - hostname: 'www.example.com', - port: 443, - path: '/some/path', - }); - - testUrl('http://us-er+na12345me:@WWW.EXAMPLE.COM:12345/some/path', { - auth: 'us-er+na12345me:', - protocol: 'http:', - scheme: 'http', - username: 'us-er+na12345me', - password: '', - host: 'www.example.com:12345', - hostname: 'www.example.com', - port: 12345, - path: '/some/path', - }); - - testUrl('https://EXAMPLE.COM:12345/some/path', { - auth: '', - protocol: 'https:', - scheme: 'https', - username: '', - password: '', // not null! - host: 'example.com:12345', - hostname: 'example.com', - port: 12345, - path: '/some/path', - }); - - testUrl('https://:passwrd@EXAMPLE.COM:12345/some/path', { - auth: ':passwrd', - protocol: 'https:', - scheme: 'https', - username: '', - password: 'passwrd', - host: 'example.com:12345', - hostname: 'example.com', - port: 12345, - path: '/some/path', - }); - - testUrl('socks5://username@EXAMPLE.com:12345/some/path', { - auth: 'username:', - protocol: 'socks5:', - scheme: 'socks5', - username: 'username', - password: '', - // TODO: Why the hell it's UPPERCASE here??? And lower-case above for EXAMPLE.COM ? - host: 'EXAMPLE.com:12345', - hostname: 'EXAMPLE.com', - port: 12345, - }); - - testUrl('FTP://@FTP.EXAMPLE.COM:12345/some/path', { - auth: '', - protocol: 'ftp:', - scheme: 'ftp', - username: '', - password: '', - hostname: 'ftp.example.com', - port: 12345, - }); - - testUrl('HTTP://www.example.com:12345/some/path', { - protocol: 'http:', - scheme: 'http', - username: '', - password: '', - hostname: 'www.example.com', - port: 12345, - path: '/some/path', - }); - - testUrl('HTTP://www.example.com/some/path', { - protocol: 'http:', - scheme: 'http', - username: '', - password: '', - port: 80, - }); - - testUrl('http://[2001:db8:85a3:8d3:1319:8a2e:370:7348]/', { - protocol: 'http:', - scheme: 'http', - username: '', - password: '', - hostname: '[2001:db8:85a3:8d3:1319:8a2e:370:7348]', - port: 80, - }); - - testUrl('http://[2001:db8:85a3:8d3:1319:8a2e:370:7348]:12345/', { - protocol: 'http:', - scheme: 'http', - username: '', - password: '', - hostname: '[2001:db8:85a3:8d3:1319:8a2e:370:7348]', - port: 12345, - }); - - // Note the upper-case "DB" here and lower-case "db" below - testUrl('http://username:password@[2001:DB8:85a3:8d3:1319:8a2e:370:7348]:12345/', { - protocol: 'http:', - scheme: 'http', - username: 'username', - password: 'password', - host: '[2001:db8:85a3:8d3:1319:8a2e:370:7348]:12345', - hostname: '[2001:db8:85a3:8d3:1319:8a2e:370:7348]', - port: 12345, - }); - - testUrl('http://user%35name:p%%w0rd@EXAMPLE.COM:12345/', { - protocol: 'http:', - scheme: 'http', - username: 'user5name', - password: 'p%%w0rd', - hostname: 'example.com', - port: 12345, - path: '/', - }); - - // Test that default ports are added for http and https - testUrl('https://www.example.com', { port: 443 }); - testUrl('http://www.example.com', { port: 80 }); - // ... and for web sockets - testUrl('wss://www.example.com', { port: 443 }); - testUrl('ws://www.example.com', { port: 80 }); - // Test that default port is not added for other protocols - testUrl('socks5://www.example.com', { port: null }); - testUrl('socks5://www.example.com:1080', { port: 1080 }); - // Test that explicit port is returned when specified - testUrl('https://www.example.com:12345', { port: 12345 }); - testUrl('http://www.example.com:12345', { port: 12345 }); - - expect(() => { - parseUrl('/some-relative-url?a=1'); - }).to.throw(/Invalid URL/); - - expect(() => { - parseUrl('A nonsense, really.'); - }).to.throw(/Invalid URL/); - }); -}); - -describe('tools.redactUrl()', () => { - it('works', () => { - // Test that the function lower-cases the schema and path - expect(redactUrl('HTTPS://username:password@WWW.EXAMPLE.COM:1234/path#hash')) - .to.eql('https://username:@www.example.com:1234/path#hash'); - - expect(redactUrl('https://username@www.example.com:1234/path#hash')) - .to.eql('https://username@www.example.com:1234/path#hash'); - - expect(redactUrl('https://username:password@www.example.com:1234/path#hash', '')) - .to.eql('https://username:@www.example.com:1234/path#hash'); - - expect(redactUrl('ftp://@www.example.com/path/path2')) - .to.eql('ftp://www.example.com/path/path2'); - - expect(redactUrl('ftp://www.example.com')) - .to.eql('ftp://www.example.com/'); - - expect(redactUrl('ftp://example.com/')) - .to.eql('ftp://example.com/'); - - expect(redactUrl('http://username:p@%%w0rd@[2001:db8:85a3:8d3:1319:8a2e:370:7348]:12345/')) - .to.eql('http://username:@[2001:db8:85a3:8d3:1319:8a2e:370:7348]:12345/'); - }); -}); - -describe('tools.parseHostHeader()', () => { - it('works with valid input', () => { - expect(parseHostHeader('www.example.com:80')).to.eql({ hostname: 'www.example.com', port: 80 }); - expect(parseHostHeader('something:1')).to.eql({ hostname: 'something', port: 1 }); - expect(parseHostHeader('something:65535')).to.eql({ hostname: 'something', port: 65535 }); - expect(parseHostHeader('example.com')).to.eql({ hostname: 'example.com', port: null }); - expect(parseHostHeader('1.2.3.4')).to.eql({ hostname: '1.2.3.4', port: null }); - expect(parseHostHeader('1.2.3.4:5555')).to.eql({ hostname: '1.2.3.4', port: 5555 }); - expect(parseHostHeader('a.b.c.d.e.f.g:1')).to.eql({ hostname: 'a.b.c.d.e.f.g', port: 1 }); - }); - - it('works with invalid input', () => { - expect(parseHostHeader(null)).to.eql(null); - expect(parseHostHeader('')).to.eql(null); - expect(parseHostHeader('bla bla')).to.eql(null); - expect(parseHostHeader(' ')).to.eql(null); - expect(parseHostHeader(' : ')).to.eql(null); - expect(parseHostHeader('12 34')).to.eql(null); - expect(parseHostHeader('example.com:')).to.eql(null); - expect(parseHostHeader('example.com:0')).to.eql(null); - expect(parseHostHeader('example.com:65536')).to.eql(null); - expect(parseHostHeader('example.com:999999')).to.eql(null); - - let LONG_HOSTNAME = ''; - for (let i = 0; i <= 256; i++) { LONG_HOSTNAME += 'a'; } - expect(parseHostHeader(LONG_HOSTNAME)).to.eql(null); - expect(parseHostHeader(`${LONG_HOSTNAME}:123`)).to.eql(null); - }); -}); - -describe('tools.isHopByHopHeader()', () => { - it('works', () => { - expect(isHopByHopHeader('Connection')).to.eql(true); - expect(isHopByHopHeader('connection')).to.eql(true); - expect(isHopByHopHeader('Proxy-Authorization')).to.eql(true); - expect(isHopByHopHeader('upGrade')).to.eql(true); - - expect(isHopByHopHeader('Host')).to.eql(false); - expect(isHopByHopHeader('Whatever')).to.eql(false); - expect(isHopByHopHeader('')).to.eql(false); - }); -}); - -describe('tools.isInvalidHeader()', () => { - it('works', () => { - expect(isInvalidHeader('With space', 'a')).to.eql(true); - expect(isInvalidHeader('', 'a')).to.eql(true); - expect(isInvalidHeader(undefined, 'a')).to.eql(true); - expect(isInvalidHeader(null, 'a')).to.eql(true); - expect(isInvalidHeader(1234, 'a')).to.eql(true); - expect(isInvalidHeader('\n', 'a')).to.eql(true); - expect(isInvalidHeader('', 'a')).to.eql(true); - expect(isInvalidHeader(' ', 'a')).to.eql(true); - expect(isInvalidHeader('\u3042', 'a')).to.eql(true); - expect(isInvalidHeader('\u3042a', 'a')).to.eql(true); - expect(isInvalidHeader('aaaa\u3042aaaa', 'a')).to.eql(true); - - expect(isInvalidHeader('connection', 'a')).to.eql(false); - expect(isInvalidHeader('Proxy-Authorization', 'a')).to.eql(false); - expect(isInvalidHeader('upGrade', 'a')).to.eql(false); - expect(isInvalidHeader('Host', 'a')).to.eql(false); - expect(isInvalidHeader('Whatever', 'a')).to.eql(false); - expect(isInvalidHeader('t', 'a')).to.eql(false); - expect(isInvalidHeader('tt', 'a')).to.eql(false); - expect(isInvalidHeader('ttt', 'a')).to.eql(false); - expect(isInvalidHeader('tttt', 'a')).to.eql(false); - expect(isInvalidHeader('ttttt', 'a')).to.eql(false); - - expect(isInvalidHeader('a', '\u3042')).to.eql(true); - expect(isInvalidHeader('a', 'aaaa\u3042aaaa')).to.eql(true); - expect(isInvalidHeader('aaa', 'bla\vbla')).to.eql(true); - - expect(isInvalidHeader('a', '')).to.eql(false); - expect(isInvalidHeader('a', 1)).to.eql(false); - expect(isInvalidHeader('a', ' ')).to.eql(false); - expect(isInvalidHeader('a', false)).to.eql(false); - expect(isInvalidHeader('a', 't')).to.eql(false); - expect(isInvalidHeader('a', 'tt')).to.eql(false); - expect(isInvalidHeader('a', 'ttt')).to.eql(false); - expect(isInvalidHeader('a', 'tttt')).to.eql(false); - expect(isInvalidHeader('a', 'ttttt')).to.eql(false); - }); -}); - -const authStr = (type, usernameAndPassword) => { - return `${type} ${Buffer.from(usernameAndPassword).toString('base64')}`; -}; - -describe('tools.parseProxyAuthorizationHeader()', () => { - it('works with valid input', () => { - const parse = parseProxyAuthorizationHeader; - - expect(parse(authStr('Basic', 'username:password'))).to.eql({ type: 'Basic', username: 'username', password: 'password' }); - expect(parse(authStr('Basic', 'user1234:password567'))).to.eql({ type: 'Basic', username: 'user1234', password: 'password567' }); - expect(parse(authStr('Basic', 'username:pass:with:many:colons'))).to.eql({ type: 'Basic', username: 'username', password: 'pass:with:many:colons' }); //eslint-disable-line - expect(parse(authStr('Basic', 'username:'))).to.eql({ type: 'Basic', username: 'username', password: '' }); - expect(parse(authStr('Basic', 'username'))).to.eql({ type: 'Basic', username: 'username', password: '' }); - expect(parse(authStr('Basic', ':'))).to.eql({ type: 'Basic', username: '', password: '' }); - expect(parse(authStr('Basic', ':passWord'))).to.eql({ type: 'Basic', username: '', password: 'passWord' }); - expect(parse(authStr('SCRAM-SHA-256', 'something:else'))).to.eql({ type: 'SCRAM-SHA-256', username: 'something', password: 'else' }); - }); - - it('works with invalid input', () => { - const parse = parseProxyAuthorizationHeader; - - expect(parse(null)).to.eql(null); - expect(parse('')).to.eql(null); - expect(parse(' ')).to.eql(null); - expect(parse('whatever')).to.eql(null); - expect(parse('bla bla bla')).to.eql(null); - expect(parse(authStr('Basic', ''))).to.eql(null); - expect(parse('123124')).to.eql(null); - }); -}); - -describe('tools.addHeader()', () => { - it('works for new header', () => { - const headers = { - foo: 'bar', - }; - - addHeader(headers, 'someHeaderName', 'someHeaderValue'); - - expect(headers).to.be.eql({ - foo: 'bar', - someHeaderName: 'someHeaderValue', - }); - }); - - it('works for existing single header with the same name', () => { - const headers = { - foo: 'bar', - someHeaderName: 'originalValue', - }; - - addHeader(headers, 'someHeaderName', 'newValue'); - - expect(headers).to.be.eql({ - foo: 'bar', - someHeaderName: ['originalValue', 'newValue'], - }); - }); - - it('works for existing multiple headers with the same name', () => { - const headers = { - foo: 'bar', - someHeaderName: ['originalValue1', 'originalValue2'], - }; - - addHeader(headers, 'someHeaderName', 'newValue'); - - expect(headers).to.be.eql({ - foo: 'bar', - someHeaderName: ['originalValue1', 'originalValue2', 'newValue'], - }); - }); -}); - -describe('tools.maybeAddProxyAuthorizationHeader()', () => { - it('works', () => { - const parsedUrl1 = parseUrl('http://example.com'); - const headers1 = { AAA: 123 }; - maybeAddProxyAuthorizationHeader(parsedUrl1, headers1); - expect(headers1).to.eql({ - AAA: 123, - }); - - const parsedUrl2 = parseUrl('http://aladdin:opensesame@userexample.com'); - const headers2 = { BBB: 123 }; - maybeAddProxyAuthorizationHeader(parsedUrl2, headers2); - expect(headers2).to.eql({ - BBB: 123, - 'Proxy-Authorization': 'Basic YWxhZGRpbjpvcGVuc2VzYW1l', - }); - - const parsedUrl3 = parseUrl('http://ala%35ddin:opensesame@userexample.com'); - const headers3 = { BBB: 123 }; - maybeAddProxyAuthorizationHeader(parsedUrl3, headers3); - expect(headers3).to.eql({ - BBB: 123, - 'Proxy-Authorization': 'Basic YWxhNWRkaW46b3BlbnNlc2FtZQ==', - }); - - const parsedUrl4 = parseUrl('http://ala%3Addin:opensesame@userexample.com'); - const headers4 = { BBB: 123 }; - expect(() => { - maybeAddProxyAuthorizationHeader(parsedUrl4, headers4); - }).to.throw(/The proxy username cannot contain the colon/); - }); -}); - -describe('tools.findFreePort()', () => { - it('throws nice error when no more free ports available', () => { - const server = net.createServer(); - const startServer = ports => new Promise((resolve, reject) => { - server.listen(ports[0], (err) => { - if (err) return reject(err); - resolve(ports[0]); - }); - }); - const PORT_SELECTION_CONFIG_BACKUP = { ...PORT_SELECTION_CONFIG }; - return portastic.find({ min: 50000, max: 50100 }) - .then(startServer) - .then((port) => { - PORT_SELECTION_CONFIG.FROM = port; - PORT_SELECTION_CONFIG.TO = port; - return findFreePort(); - }) - .then(() => assert.fail()) - .catch((err) => { - expect(err.message).to.contain('There are no more free ports'); - }) - .finally(() => { - PORT_SELECTION_CONFIG.FROM = PORT_SELECTION_CONFIG_BACKUP.FROM; - PORT_SELECTION_CONFIG.TO = PORT_SELECTION_CONFIG_BACKUP.TO; - if (server.listening) server.close(); - }); - }); -}); - -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); - let result; - try { - result = 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(error.message).to.eql('Test error'); - resolve(); - }); - }); - } - }); -}); - -module.exports = { - PORT_SELECTION_CONFIG, - findFreePort, -}; diff --git a/test/unit/tools.js b/test/unit/tools.js new file mode 100644 index 00000000..37bbab84 --- /dev/null +++ b/test/unit/tools.js @@ -0,0 +1,136 @@ +import { expect } from 'chai'; +import { redactUrl } from '../../src/utils/redact_url.js'; +import { isHopByHopHeader } from '../../src/utils/is_hop_by_hop_header.js'; +import { parseAuthorizationHeader } from '../../src/utils/parse_authorization_header.js'; + +describe('tools.redactUrl()', () => { + it('works', () => { + // Test that the function lower-cases the schema and path + expect(redactUrl('HTTPS://username:password@WWW.EXAMPLE.COM:1234/path#hash')) + .to.eql('https://username:@www.example.com:1234/path#hash'); + + expect(redactUrl('https://username@www.example.com:1234/path#hash')) + .to.eql('https://username@www.example.com:1234/path#hash'); + + expect(redactUrl('https://username:password@www.example.com:1234/path#hash', '')) + .to.eql('https://username:@www.example.com:1234/path#hash'); + + expect(redactUrl('ftp://@www.example.com/path/path2')) + .to.eql('ftp://www.example.com/path/path2'); + + expect(redactUrl('ftp://www.example.com')) + .to.eql('ftp://www.example.com/'); + + expect(redactUrl('ftp://example.com/')) + .to.eql('ftp://example.com/'); + + expect(redactUrl('http://username:p@%%w0rd@[2001:db8:85a3:8d3:1319:8a2e:370:7348]:12345/')) + .to.eql('http://username:@[2001:db8:85a3:8d3:1319:8a2e:370:7348]:12345/'); + }); +}); + +describe('tools.isHopByHopHeader()', () => { + it('works', () => { + expect(isHopByHopHeader('Connection')).to.eql(true); + expect(isHopByHopHeader('connection')).to.eql(true); + expect(isHopByHopHeader('Proxy-Authorization')).to.eql(true); + expect(isHopByHopHeader('upGrade')).to.eql(true); + + expect(isHopByHopHeader('Host')).to.eql(false); + expect(isHopByHopHeader('Whatever')).to.eql(false); + expect(isHopByHopHeader('')).to.eql(false); + }); +}); + +const authStr = (type, usernameAndPassword) => { + return `${type} ${Buffer.from(usernameAndPassword).toString('base64')}`; +}; + +describe('tools.parseAuthorizationHeader()', () => { + it('works with valid input', () => { + const parse = parseAuthorizationHeader; + + expect(parse(authStr('Basic', 'username:password'))).to.eql({ + type: 'Basic', + username: 'username', + password: 'password', + data: 'dXNlcm5hbWU6cGFzc3dvcmQ=', + }); + + expect(parse(authStr('Basic', 'user1234:password567'))).to.eql({ + type: 'Basic', + username: 'user1234', + password: 'password567', + data: 'dXNlcjEyMzQ6cGFzc3dvcmQ1Njc=', + }); + + expect(parse(authStr('Basic', 'username:pass:with:many:colons'))).to.eql({ + type: 'Basic', + username: 'username', + password: 'pass:with:many:colons', + data: 'dXNlcm5hbWU6cGFzczp3aXRoOm1hbnk6Y29sb25z', + }); + + expect(parse(authStr('Basic', 'username:'))).to.eql({ + type: 'Basic', + username: 'username', + password: '', + data: 'dXNlcm5hbWU6', + }); + + // Do not alter this test, see comment in src/utils/parse_authorization_header.ts + expect(parse(authStr('Basic', 'username'))).to.eql({ + type: 'Basic', + username: 'username', + password: '', + data: 'dXNlcm5hbWU=', + }); + + expect(parse(authStr('Basic', ':'))).to.eql({ + type: 'Basic', + username: '', + password: '', + data: 'Og==', + }); + + expect(parse(authStr('Basic', ':passWord'))).to.eql({ + type: 'Basic', + username: '', + password: 'passWord', + data: 'OnBhc3NXb3Jk', + }); + + expect(parse(authStr('SCRAM-SHA-256', 'something:else'))).to.eql({ + type: 'SCRAM-SHA-256', + data: 'c29tZXRoaW5nOmVsc2U=', + }); + }); + + it('works with invalid input', () => { + const parse = parseAuthorizationHeader; + + expect(parse(null)).to.eql(null); + expect(parse('')).to.eql(null); + expect(parse(' ')).to.eql(null); + + expect(parse('whatever')).to.eql({ + type: '', + data: '', + }); + + expect(parse('bla bla bla')).to.eql({ + type: 'bla', + data: 'bla bla', + }); + + expect(parse(authStr('Basic', ''))).to.eql({ + type: '', + data: '', + }); + + expect(parse('123124')).to.eql({ + type: '', + data: '', + }); + }); +}); diff --git a/src/run_locally.js b/test/utils/run_locally.js similarity index 94% rename from src/run_locally.js rename to test/utils/run_locally.js index 93d0ea50..928a1cb3 100644 --- a/src/run_locally.js +++ b/test/utils/run_locally.js @@ -2,16 +2,16 @@ * This script runs the proxy with a second upstream proxy locally on port specified by PORT environment variable * or 8080 if not provided. This is used to manually test the proxy on normal browsing. * - * node ./build/run_locally.js + * npm run local-proxy * * Author: Jan Curn (jan@apify.com) * Copyright(c) 2017 Apify Technologies. All rights reserved. * */ -import http from 'http'; +import http from 'node:http'; import proxy from 'proxy'; // eslint-disable-line import/no-extraneous-dependencies -import { Server } from './server'; +import { Server } from '../../src/index.js'; // Set up upstream proxy with no auth const upstreamProxyHttpServer = http.createServer(); diff --git a/test/target_server.js b/test/utils/target_server.js similarity index 89% rename from test/target_server.js rename to test/utils/target_server.js index eaa4f5a0..946aad8e 100644 --- a/test/target_server.js +++ b/test/utils/target_server.js @@ -1,12 +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. @@ -43,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)); } @@ -76,17 +79,13 @@ class TargetServer { get1MACharsTogether(request, response) { response.writeHead(200, { 'Content-Type': 'text/plain' }); - let str = ''; - for (let i = 0; i < 10000; i++) { - str += 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - } - response.end(str); + response.end(''.padStart(1000 * 1000, 'a')); } get1MACharsStreamed(request, response) { response.writeHead(200, { 'Content-Type': 'text/plain' }); for (let i = 0; i < 10000; i++) { - response.write('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + response.write(`${''.padStart(99, 'a')}\n`); } response.end(); } @@ -194,4 +193,4 @@ class TargetServer { } } -exports.TargetServer = TargetServer; +export { TargetServer }; diff --git a/src/testing_tcp_service.js b/test/utils/testing_tcp_service.js similarity index 96% rename from src/testing_tcp_service.js rename to test/utils/testing_tcp_service.js index 5db3431e..53cda5e6 100644 --- a/src/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 new file mode 100644 index 00000000..407c3153 --- /dev/null +++ b/test/utils/throws_async.js @@ -0,0 +1,25 @@ +import { expect } from 'chai'; + +/** + * Expect an async function to throw + * @param {*} func Async function to be tested + * @param {*} errorMessage Error message to be expected, can be a string or a RegExp + */ +const expectThrowsAsync = async (func, errorMessage) => { + let error = null; + try { + await func(); + } catch (err) { + error = err; + } + expect(error).to.be.an('Error'); + if (errorMessage) { + if (errorMessage instanceof RegExp) { + expect(error.message).to.match(errorMessage); + } else { + expect(error.message).to.contain(errorMessage); + } + } +}; + +export { expectThrowsAsync }; diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 00000000..5d0f8c20 --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src", + "jest.config.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..194b4f1e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@apify/tsconfig", + "compilerOptions": { + "outDir": "dist", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "verbatimModuleSyntax": true, + "declarationMap": false, + "sourceMap": false + }, + "include": ["src"] +}