From bcfbfc69a29073d37f4261d52823f6b725fb1411 Mon Sep 17 00:00:00 2001 From: zhouganqing Date: Fri, 17 Feb 2023 16:50:44 +0800 Subject: [PATCH] Import Upstream version 5.0.0+~cs8.0.0 --- .editorconfig | 37 ++ .eslintrc.js | 86 ++++ .github/workflows/test.yml | 46 ++ .gitignore | 4 + .npmignore | 3 + README.md | 137 ++++++ http-proxy-agent/.editorconfig | 37 ++ http-proxy-agent/.eslintrc.js | 86 ++++ http-proxy-agent/.github/workflows/test.yml | 46 ++ http-proxy-agent/.gitignore | 4 + http-proxy-agent/README.md | 74 ++++ http-proxy-agent/package.json | 57 +++ http-proxy-agent/src/agent.ts | 169 ++++++++ http-proxy-agent/src/index.ts | 37 ++ http-proxy-agent/test/ssl-cert-snakeoil.key | 15 + http-proxy-agent/test/ssl-cert-snakeoil.pem | 12 + http-proxy-agent/test/test.js | 389 +++++++++++++++++ http-proxy-agent/tsconfig.json | 18 + package.json | 56 +++ src/agent.ts | 215 +++++++++ src/index.ts | 39 ++ src/parse-proxy-response.ts | 82 ++++ test/ssl-cert-snakeoil.key | 15 + test/ssl-cert-snakeoil.pem | 12 + test/test.js | 410 ++++++++++++++++++ tootallnate-once/.editorconfig | 37 ++ tootallnate-once/.github/workflows/test.yml | 40 ++ tootallnate-once/.gitignore | 6 + tootallnate-once/LICENSE | 21 + tootallnate-once/README.md | 93 ++++ tootallnate-once/package.json | 54 +++ .../scripts/gen-overloaded-parameters.ts | 28 ++ tootallnate-once/src/index.ts | 34 ++ tootallnate-once/src/overloaded-parameters.ts | 22 + tootallnate-once/src/types.ts | 38 ++ tootallnate-once/test/once.test.ts | 117 +++++ tootallnate-once/test/tsconfig.json | 4 + tootallnate-once/tsconfig.json | 24 + tsconfig.json | 18 + 39 files changed, 2622 insertions(+) create mode 100644 .editorconfig create mode 100644 .eslintrc.js create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 README.md create mode 100644 http-proxy-agent/.editorconfig create mode 100644 http-proxy-agent/.eslintrc.js create mode 100644 http-proxy-agent/.github/workflows/test.yml create mode 100644 http-proxy-agent/.gitignore create mode 100644 http-proxy-agent/README.md create mode 100644 http-proxy-agent/package.json create mode 100644 http-proxy-agent/src/agent.ts create mode 100644 http-proxy-agent/src/index.ts create mode 100644 http-proxy-agent/test/ssl-cert-snakeoil.key create mode 100644 http-proxy-agent/test/ssl-cert-snakeoil.pem create mode 100644 http-proxy-agent/test/test.js create mode 100644 http-proxy-agent/tsconfig.json create mode 100644 package.json create mode 100644 src/agent.ts create mode 100644 src/index.ts create mode 100644 src/parse-proxy-response.ts create mode 100644 test/ssl-cert-snakeoil.key create mode 100644 test/ssl-cert-snakeoil.pem create mode 100644 test/test.js create mode 100644 tootallnate-once/.editorconfig create mode 100644 tootallnate-once/.github/workflows/test.yml create mode 100644 tootallnate-once/.gitignore create mode 100644 tootallnate-once/LICENSE create mode 100644 tootallnate-once/README.md create mode 100644 tootallnate-once/package.json create mode 100644 tootallnate-once/scripts/gen-overloaded-parameters.ts create mode 100644 tootallnate-once/src/index.ts create mode 100644 tootallnate-once/src/overloaded-parameters.ts create mode 100644 tootallnate-once/src/types.ts create mode 100644 tootallnate-once/test/once.test.ts create mode 100644 tootallnate-once/test/tsconfig.json create mode 100644 tootallnate-once/tsconfig.json create mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..12b4b9a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,37 @@ +root = true + +[*] +indent_style = tab +indent_size = 4 +tab_width = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[{*.json,*.json.example,*.gyp,*.yml,*.yaml,*.workflow}] +indent_style = space +indent_size = 2 + +[{*.py,*.asm}] +indent_style = space + +[*.py] +indent_size = 4 + +[*.asm] +indent_size = 8 + +[*.md] +trim_trailing_whitespace = false + +# Ideal settings - some plugins might support these. +[*.js] +quote_type = single + +[{*.c,*.cc,*.h,*.hh,*.cpp,*.hpp,*.m,*.mm,*.mpp,*.js,*.java,*.go,*.rs,*.php,*.ng,*.jsx,*.ts,*.d,*.cs,*.swift}] +curly_bracket_next_line = false +spaces_around_operators = true +spaces_around_brackets = outside +# close enough to 1TB +indent_brace_style = K&R diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..62743f2 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,86 @@ +module.exports = { + 'extends': [ + 'airbnb', + 'prettier' + ], + 'parser': '@typescript-eslint/parser', + 'parserOptions': { + 'ecmaVersion': 2018, + 'sourceType': 'module', + 'modules': true + }, + 'plugins': [ + '@typescript-eslint' + ], + 'settings': { + 'import/resolver': { + 'typescript': { + } + } + }, + 'rules': { + 'quotes': [ + 2, + 'single', + { + 'allowTemplateLiterals': true + } + ], + 'class-methods-use-this': 0, + 'consistent-return': 0, + 'func-names': 0, + 'global-require': 0, + 'guard-for-in': 0, + 'import/no-duplicates': 0, + 'import/no-dynamic-require': 0, + 'import/no-extraneous-dependencies': 0, + 'import/prefer-default-export': 0, + 'lines-between-class-members': 0, + 'no-await-in-loop': 0, + 'no-bitwise': 0, + 'no-console': 0, + 'no-continue': 0, + 'no-control-regex': 0, + 'no-empty': 0, + 'no-loop-func': 0, + 'no-nested-ternary': 0, + 'no-param-reassign': 0, + 'no-plusplus': 0, + 'no-restricted-globals': 0, + 'no-restricted-syntax': 0, + 'no-shadow': 0, + 'no-underscore-dangle': 0, + 'no-use-before-define': 0, + 'prefer-const': 0, + 'prefer-destructuring': 0, + 'camelcase': 0, + 'no-unused-vars': 0, // in favor of '@typescript-eslint/no-unused-vars' + // 'indent': 0 // in favor of '@typescript-eslint/indent' + '@typescript-eslint/no-unused-vars': 'warn', + // '@typescript-eslint/indent': ['error', 2] // this might conflict with a lot ongoing changes + '@typescript-eslint/no-array-constructor': 'error', + '@typescript-eslint/adjacent-overload-signatures': 'error', + '@typescript-eslint/class-name-casing': 'error', + '@typescript-eslint/interface-name-prefix': 'error', + '@typescript-eslint/no-empty-interface': 'error', + '@typescript-eslint/no-inferrable-types': 'error', + '@typescript-eslint/no-misused-new': 'error', + '@typescript-eslint/no-namespace': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/no-parameter-properties': 'error', + '@typescript-eslint/no-triple-slash-reference': 'error', + '@typescript-eslint/prefer-namespace-keyword': 'error', + '@typescript-eslint/type-annotation-spacing': 'error', + // '@typescript-eslint/array-type': 'error', + // '@typescript-eslint/ban-types': 'error', + // '@typescript-eslint/explicit-function-return-type': 'warn', + // '@typescript-eslint/explicit-member-accessibility': 'error', + // '@typescript-eslint/member-delimiter-style': 'error', + // '@typescript-eslint/no-angle-bracket-type-assertion': 'error', + // '@typescript-eslint/no-explicit-any': 'warn', + // '@typescript-eslint/no-object-literal-type-assertion': 'error', + // '@typescript-eslint/no-use-before-define': 'error', + // '@typescript-eslint/no-var-requires': 'error', + // '@typescript-eslint/prefer-interface': 'error' + } +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..329914f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,46 @@ +name: Node CI + +on: + push: + branches: + - master + tags: + - '!*' + pull_request: + +jobs: + build: + name: Test Node.js ${{ matrix.node-version }} on ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node-version: [6.x, 8.x, 10.x, 12.x] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v1 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Print Node.js Version + run: node --version + + - name: Install Dependencies + run: npm install + env: + CI: true + + - name: Run "build" step + run: npm run build --if-present + env: + CI: true + + - name: Run tests + run: npm test + env: + CI: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1d74da --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/node_modules +/yarn.lock +/?.?s +/dist diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..4d6db5e --- /dev/null +++ b/.npmignore @@ -0,0 +1,3 @@ +History.md +test +.travis.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..328656a --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +https-proxy-agent +================ +### An HTTP(s) proxy `http.Agent` implementation for HTTPS +[![Build Status](https://github.com/TooTallNate/node-https-proxy-agent/workflows/Node%20CI/badge.svg)](https://github.com/TooTallNate/node-https-proxy-agent/actions?workflow=Node+CI) + +This module provides an `http.Agent` implementation that connects to a specified +HTTP or HTTPS proxy server, and can be used with the built-in `https` module. + +Specifically, this `Agent` implementation connects to an intermediary "proxy" +server and issues the [CONNECT HTTP method][CONNECT], which tells the proxy to +open a direct TCP connection to the destination server. + +Since this agent implements the CONNECT HTTP method, it also works with other +protocols that use this method when connecting over proxies (i.e. WebSockets). +See the "Examples" section below for more. + + +Installation +------------ + +Install with `npm`: + +``` bash +$ npm install https-proxy-agent +``` + + +Examples +-------- + +#### `https` module example + +``` js +var url = require('url'); +var https = require('https'); +var HttpsProxyAgent = require('https-proxy-agent'); + +// HTTP/HTTPS proxy to connect to +var proxy = process.env.http_proxy || 'http://168.63.76.32:3128'; +console.log('using proxy server %j', proxy); + +// HTTPS endpoint for the proxy to connect to +var endpoint = process.argv[2] || 'https://graph.facebook.com/tootallnate'; +console.log('attempting to GET %j', endpoint); +var options = url.parse(endpoint); + +// create an instance of the `HttpsProxyAgent` class with the proxy server information +var agent = new HttpsProxyAgent(proxy); +options.agent = agent; + +https.get(options, function (res) { + console.log('"response" event!', res.headers); + res.pipe(process.stdout); +}); +``` + +#### `ws` WebSocket connection example + +``` js +var url = require('url'); +var WebSocket = require('ws'); +var HttpsProxyAgent = require('https-proxy-agent'); + +// HTTP/HTTPS proxy to connect to +var proxy = process.env.http_proxy || 'http://168.63.76.32:3128'; +console.log('using proxy server %j', proxy); + +// WebSocket endpoint for the proxy to connect to +var endpoint = process.argv[2] || 'ws://echo.websocket.org'; +var parsed = url.parse(endpoint); +console.log('attempting to connect to WebSocket %j', endpoint); + +// create an instance of the `HttpsProxyAgent` class with the proxy server information +var options = url.parse(proxy); + +var agent = new HttpsProxyAgent(options); + +// finally, initiate the WebSocket connection +var socket = new WebSocket(endpoint, { agent: agent }); + +socket.on('open', function () { + console.log('"open" event!'); + socket.send('hello world'); +}); + +socket.on('message', function (data, flags) { + console.log('"message" event! %j %j', data, flags); + socket.close(); +}); +``` + +API +--- + +### new HttpsProxyAgent(Object options) + +The `HttpsProxyAgent` class implements an `http.Agent` subclass that connects +to the specified "HTTP(s) proxy server" in order to proxy HTTPS and/or WebSocket +requests. This is achieved by using the [HTTP `CONNECT` method][CONNECT]. + +The `options` argument may either be a string URI of the proxy server to use, or an +"options" object with more specific properties: + + * `host` - String - Proxy host to connect to (may use `hostname` as well). Required. + * `port` - Number - Proxy port to connect to. Required. + * `protocol` - String - If `https:`, then use TLS to connect to the proxy. + * `headers` - Object - Additional HTTP headers to be sent on the HTTP CONNECT method. + * Any other options given are passed to the `net.connect()`/`tls.connect()` functions. + + +License +------- + +(The MIT License) + +Copyright (c) 2013 Nathan Rajlich <nathan@tootallnate.net> + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +[CONNECT]: http://en.wikipedia.org/wiki/HTTP_tunnel#HTTP_CONNECT_Tunneling diff --git a/http-proxy-agent/.editorconfig b/http-proxy-agent/.editorconfig new file mode 100644 index 0000000..12b4b9a --- /dev/null +++ b/http-proxy-agent/.editorconfig @@ -0,0 +1,37 @@ +root = true + +[*] +indent_style = tab +indent_size = 4 +tab_width = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[{*.json,*.json.example,*.gyp,*.yml,*.yaml,*.workflow}] +indent_style = space +indent_size = 2 + +[{*.py,*.asm}] +indent_style = space + +[*.py] +indent_size = 4 + +[*.asm] +indent_size = 8 + +[*.md] +trim_trailing_whitespace = false + +# Ideal settings - some plugins might support these. +[*.js] +quote_type = single + +[{*.c,*.cc,*.h,*.hh,*.cpp,*.hpp,*.m,*.mm,*.mpp,*.js,*.java,*.go,*.rs,*.php,*.ng,*.jsx,*.ts,*.d,*.cs,*.swift}] +curly_bracket_next_line = false +spaces_around_operators = true +spaces_around_brackets = outside +# close enough to 1TB +indent_brace_style = K&R diff --git a/http-proxy-agent/.eslintrc.js b/http-proxy-agent/.eslintrc.js new file mode 100644 index 0000000..62743f2 --- /dev/null +++ b/http-proxy-agent/.eslintrc.js @@ -0,0 +1,86 @@ +module.exports = { + 'extends': [ + 'airbnb', + 'prettier' + ], + 'parser': '@typescript-eslint/parser', + 'parserOptions': { + 'ecmaVersion': 2018, + 'sourceType': 'module', + 'modules': true + }, + 'plugins': [ + '@typescript-eslint' + ], + 'settings': { + 'import/resolver': { + 'typescript': { + } + } + }, + 'rules': { + 'quotes': [ + 2, + 'single', + { + 'allowTemplateLiterals': true + } + ], + 'class-methods-use-this': 0, + 'consistent-return': 0, + 'func-names': 0, + 'global-require': 0, + 'guard-for-in': 0, + 'import/no-duplicates': 0, + 'import/no-dynamic-require': 0, + 'import/no-extraneous-dependencies': 0, + 'import/prefer-default-export': 0, + 'lines-between-class-members': 0, + 'no-await-in-loop': 0, + 'no-bitwise': 0, + 'no-console': 0, + 'no-continue': 0, + 'no-control-regex': 0, + 'no-empty': 0, + 'no-loop-func': 0, + 'no-nested-ternary': 0, + 'no-param-reassign': 0, + 'no-plusplus': 0, + 'no-restricted-globals': 0, + 'no-restricted-syntax': 0, + 'no-shadow': 0, + 'no-underscore-dangle': 0, + 'no-use-before-define': 0, + 'prefer-const': 0, + 'prefer-destructuring': 0, + 'camelcase': 0, + 'no-unused-vars': 0, // in favor of '@typescript-eslint/no-unused-vars' + // 'indent': 0 // in favor of '@typescript-eslint/indent' + '@typescript-eslint/no-unused-vars': 'warn', + // '@typescript-eslint/indent': ['error', 2] // this might conflict with a lot ongoing changes + '@typescript-eslint/no-array-constructor': 'error', + '@typescript-eslint/adjacent-overload-signatures': 'error', + '@typescript-eslint/class-name-casing': 'error', + '@typescript-eslint/interface-name-prefix': 'error', + '@typescript-eslint/no-empty-interface': 'error', + '@typescript-eslint/no-inferrable-types': 'error', + '@typescript-eslint/no-misused-new': 'error', + '@typescript-eslint/no-namespace': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/no-parameter-properties': 'error', + '@typescript-eslint/no-triple-slash-reference': 'error', + '@typescript-eslint/prefer-namespace-keyword': 'error', + '@typescript-eslint/type-annotation-spacing': 'error', + // '@typescript-eslint/array-type': 'error', + // '@typescript-eslint/ban-types': 'error', + // '@typescript-eslint/explicit-function-return-type': 'warn', + // '@typescript-eslint/explicit-member-accessibility': 'error', + // '@typescript-eslint/member-delimiter-style': 'error', + // '@typescript-eslint/no-angle-bracket-type-assertion': 'error', + // '@typescript-eslint/no-explicit-any': 'warn', + // '@typescript-eslint/no-object-literal-type-assertion': 'error', + // '@typescript-eslint/no-use-before-define': 'error', + // '@typescript-eslint/no-var-requires': 'error', + // '@typescript-eslint/prefer-interface': 'error' + } +} diff --git a/http-proxy-agent/.github/workflows/test.yml b/http-proxy-agent/.github/workflows/test.yml new file mode 100644 index 0000000..329914f --- /dev/null +++ b/http-proxy-agent/.github/workflows/test.yml @@ -0,0 +1,46 @@ +name: Node CI + +on: + push: + branches: + - master + tags: + - '!*' + pull_request: + +jobs: + build: + name: Test Node.js ${{ matrix.node-version }} on ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node-version: [6.x, 8.x, 10.x, 12.x] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v1 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Print Node.js Version + run: node --version + + - name: Install Dependencies + run: npm install + env: + CI: true + + - name: Run "build" step + run: npm run build --if-present + env: + CI: true + + - name: Run tests + run: npm test + env: + CI: true diff --git a/http-proxy-agent/.gitignore b/http-proxy-agent/.gitignore new file mode 100644 index 0000000..f86c8fc --- /dev/null +++ b/http-proxy-agent/.gitignore @@ -0,0 +1,4 @@ +/dist +/yarn.lock +/node_modules +/?.?s diff --git a/http-proxy-agent/README.md b/http-proxy-agent/README.md new file mode 100644 index 0000000..d60e206 --- /dev/null +++ b/http-proxy-agent/README.md @@ -0,0 +1,74 @@ +http-proxy-agent +================ +### An HTTP(s) proxy `http.Agent` implementation for HTTP +[![Build Status](https://github.com/TooTallNate/node-http-proxy-agent/workflows/Node%20CI/badge.svg)](https://github.com/TooTallNate/node-http-proxy-agent/actions?workflow=Node+CI) + +This module provides an `http.Agent` implementation that connects to a specified +HTTP or HTTPS proxy server, and can be used with the built-in `http` module. + +__Note:__ For HTTP proxy usage with the `https` module, check out +[`node-https-proxy-agent`](https://github.com/TooTallNate/node-https-proxy-agent). + +Installation +------------ + +Install with `npm`: + +``` bash +$ npm install http-proxy-agent +``` + + +Example +------- + +``` js +var url = require('url'); +var http = require('http'); +var HttpProxyAgent = require('http-proxy-agent'); + +// HTTP/HTTPS proxy to connect to +var proxy = process.env.http_proxy || 'http://168.63.76.32:3128'; +console.log('using proxy server %j', proxy); + +// HTTP endpoint for the proxy to connect to +var endpoint = process.argv[2] || 'http://nodejs.org/api/'; +console.log('attempting to GET %j', endpoint); +var opts = url.parse(endpoint); + +// create an instance of the `HttpProxyAgent` class with the proxy server information +var agent = new HttpProxyAgent(proxy); +opts.agent = agent; + +http.get(opts, function (res) { + console.log('"response" event!', res.headers); + res.pipe(process.stdout); +}); +``` + + +License +------- + +(The MIT License) + +Copyright (c) 2013 Nathan Rajlich <nathan@tootallnate.net> + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/http-proxy-agent/package.json b/http-proxy-agent/package.json new file mode 100644 index 0000000..659d6e1 --- /dev/null +++ b/http-proxy-agent/package.json @@ -0,0 +1,57 @@ +{ + "name": "http-proxy-agent", + "version": "5.0.0", + "description": "An HTTP(s) proxy `http.Agent` implementation for HTTP", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "prebuild": "rimraf dist", + "build": "tsc", + "test": "mocha", + "test-lint": "eslint src --ext .js,.ts", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git://github.com/TooTallNate/node-http-proxy-agent.git" + }, + "keywords": [ + "http", + "proxy", + "endpoint", + "agent" + ], + "author": "Nathan Rajlich (http://n8.io/)", + "license": "MIT", + "bugs": { + "url": "https://github.com/TooTallNate/node-http-proxy-agent/issues" + }, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "devDependencies": { + "@types/debug": "4", + "@types/node": "^12.19.2", + "@typescript-eslint/eslint-plugin": "1.6.0", + "@typescript-eslint/parser": "1.1.0", + "eslint": "5.16.0", + "eslint-config-airbnb": "17.1.0", + "eslint-config-prettier": "4.1.0", + "eslint-import-resolver-typescript": "1.1.1", + "eslint-plugin-import": "2.16.0", + "eslint-plugin-jsx-a11y": "6.2.1", + "eslint-plugin-react": "7.12.4", + "mocha": "^6.2.2", + "proxy": "1", + "rimraf": "^3.0.0", + "typescript": "^4.4.3" + }, + "engines": { + "node": ">= 6" + } +} diff --git a/http-proxy-agent/src/agent.ts b/http-proxy-agent/src/agent.ts new file mode 100644 index 0000000..097befd --- /dev/null +++ b/http-proxy-agent/src/agent.ts @@ -0,0 +1,169 @@ +import net from 'net'; +import tls from 'tls'; +import url from 'url'; +import createDebug from 'debug'; +import once from '@tootallnate/once'; +import { Agent, ClientRequest, RequestOptions } from 'agent-base'; +import { HttpProxyAgentOptions } from '.'; + +const debug = createDebug('http-proxy-agent'); + +interface HttpProxyAgentClientRequest extends ClientRequest { + path: string; + output?: string[]; + outputData?: { + data: string; + }[]; + _header?: string | null; + _implicitHeader(): void; +} + +function isHTTPS(protocol?: string | null): boolean { + return typeof protocol === 'string' ? /^https:?$/i.test(protocol) : false; +} + +/** + * The `HttpProxyAgent` implements an HTTP Agent subclass that connects + * to the specified "HTTP proxy server" in order to proxy HTTP requests. + * + * @api public + */ +export default class HttpProxyAgent extends Agent { + private secureProxy: boolean; + private proxy: HttpProxyAgentOptions; + + constructor(_opts: string | HttpProxyAgentOptions) { + let opts: HttpProxyAgentOptions; + if (typeof _opts === 'string') { + opts = url.parse(_opts); + } else { + opts = _opts; + } + if (!opts) { + throw new Error( + 'an HTTP(S) proxy server `host` and `port` must be specified!' + ); + } + debug('Creating new HttpProxyAgent instance: %o', opts); + super(opts); + + const proxy: HttpProxyAgentOptions = { ...opts }; + + // If `true`, then connect to the proxy server over TLS. + // Defaults to `false`. + this.secureProxy = opts.secureProxy || isHTTPS(proxy.protocol); + + // Prefer `hostname` over `host`, and set the `port` if needed. + proxy.host = proxy.hostname || proxy.host; + if (typeof proxy.port === 'string') { + proxy.port = parseInt(proxy.port, 10); + } + if (!proxy.port && proxy.host) { + proxy.port = this.secureProxy ? 443 : 80; + } + + if (proxy.host && proxy.path) { + // If both a `host` and `path` are specified then it's most likely + // the result of a `url.parse()` call... we need to remove the + // `path` portion so that `net.connect()` doesn't attempt to open + // that as a Unix socket file. + delete proxy.path; + delete proxy.pathname; + } + + this.proxy = proxy; + } + + /** + * Called when the node-core HTTP client library is creating a + * new HTTP request. + * + * @api protected + */ + async callback( + req: HttpProxyAgentClientRequest, + opts: RequestOptions + ): Promise { + const { proxy, secureProxy } = this; + const parsed = url.parse(req.path); + + if (!parsed.protocol) { + parsed.protocol = 'http:'; + } + + if (!parsed.hostname) { + parsed.hostname = opts.hostname || opts.host || null; + } + + if (parsed.port == null && typeof opts.port) { + parsed.port = String(opts.port); + } + + if (parsed.port === '80') { + // if port is 80, then we can remove the port so that the + // ":80" portion is not on the produced URL + parsed.port = ''; + } + + // Change the `http.ClientRequest` instance's "path" field + // to the absolute path of the URL that will be requested. + req.path = url.format(parsed); + + // Inject the `Proxy-Authorization` header if necessary. + if (proxy.auth) { + req.setHeader( + 'Proxy-Authorization', + `Basic ${Buffer.from(proxy.auth).toString('base64')}` + ); + } + + // Create a socket connection to the proxy server. + let socket: net.Socket; + if (secureProxy) { + debug('Creating `tls.Socket`: %o', proxy); + socket = tls.connect(proxy as tls.ConnectionOptions); + } else { + debug('Creating `net.Socket`: %o', proxy); + socket = net.connect(proxy as net.NetConnectOpts); + } + + // At this point, the http ClientRequest's internal `_header` field + // might have already been set. If this is the case then we'll need + // to re-generate the string since we just changed the `req.path`. + if (req._header) { + let first: string; + let endOfHeaders: number; + debug('Regenerating stored HTTP header string for request'); + req._header = null; + req._implicitHeader(); + if (req.output && req.output.length > 0) { + // Node < 12 + debug( + 'Patching connection write() output buffer with updated header' + ); + first = req.output[0]; + endOfHeaders = first.indexOf('\r\n\r\n') + 4; + req.output[0] = req._header + first.substring(endOfHeaders); + debug('Output buffer: %o', req.output); + } else if (req.outputData && req.outputData.length > 0) { + // Node >= 12 + debug( + 'Patching connection write() output buffer with updated header' + ); + first = req.outputData[0].data; + endOfHeaders = first.indexOf('\r\n\r\n') + 4; + req.outputData[0].data = + req._header + first.substring(endOfHeaders); + debug('Output buffer: %o', req.outputData[0].data); + } + } + + // Wait for the socket's `connect` event, so that this `callback()` + // function throws instead of the `http` request machinery. This is + // important for i.e. `PacProxyAgent` which determines a failed proxy + // connection via the `callback()` function throwing. + await once(socket, 'connect'); + + return socket; + } +} diff --git a/http-proxy-agent/src/index.ts b/http-proxy-agent/src/index.ts new file mode 100644 index 0000000..1e24e3b --- /dev/null +++ b/http-proxy-agent/src/index.ts @@ -0,0 +1,37 @@ +import net from 'net'; +import tls from 'tls'; +import { Url } from 'url'; +import { AgentOptions } from 'agent-base'; +import _HttpProxyAgent from './agent'; + +function createHttpProxyAgent( + opts: string | createHttpProxyAgent.HttpProxyAgentOptions +): _HttpProxyAgent { + return new _HttpProxyAgent(opts); +} + +namespace createHttpProxyAgent { + interface BaseHttpProxyAgentOptions { + secureProxy?: boolean; + host?: string | null; + path?: string | null; + port?: string | number | null; + } + + export interface HttpProxyAgentOptions + extends AgentOptions, + BaseHttpProxyAgentOptions, + Partial< + Omit< + Url & net.NetConnectOpts & tls.ConnectionOptions, + keyof BaseHttpProxyAgentOptions + > + > {} + + export type HttpProxyAgent = _HttpProxyAgent; + export const HttpProxyAgent = _HttpProxyAgent; + + createHttpProxyAgent.prototype = _HttpProxyAgent.prototype; +} + +export = createHttpProxyAgent; diff --git a/http-proxy-agent/test/ssl-cert-snakeoil.key b/http-proxy-agent/test/ssl-cert-snakeoil.key new file mode 100644 index 0000000..fd12501 --- /dev/null +++ b/http-proxy-agent/test/ssl-cert-snakeoil.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICWwIBAAKBgQCzURxIqzer0ACAbX/lHdsn4Gd9PLKrf7EeDYfIdV0HZKPD8WDr +bBx2/fBu0OW2sjnzv/SVZbJ0DAuPE/p0+eT0qb2qC10iz9iTD7ribd7gxhirVb8y +b3fBjXsxc8V8p4Ny1LcvNSqCjwUbJqdRogfoJeTiqPM58z5sNzuv5iq7iwIDAQAB +AoGAPMQy4olrP0UotlzlJ36bowLP70ffgHCwU+/f4NWs5fF78c3du0oSx1w820Dd +Z7E0JF8bgnlJJTxjumPZz0RUCugrEHBKJmzEz3cxF5E3+7NvteZcjKn9D67RrM5x +1/uSZ9cqKE9cYvY4fSuHx18diyZ4axR/wB1Pea2utjjDM+ECQQDb9ZbmmaWMiRpQ +5Up+loxP7BZNPsEVsm+DVJmEFbaFgGfncWBqSIqnPNjMwTwj0OigTwCAEGPkfRVW +T0pbYWCxAkEA0LK7SCTwzyDmhASUalk0x+3uCAA6ryFdwJf/wd8TRAvVOmkTEldX +uJ7ldLvfrONYO3v56uKTU/SoNdZYzKtO+wJAX2KM4ctXYy5BXztPpr2acz4qHa1N +Bh+vBAC34fOYhyQ76r3b1btHhWZ5jbFuZwm9F2erC94Ps5IaoqcX07DSwQJAPKGw +h2U0EPkd/3zVIZCJJQya+vgWFIs9EZcXVtvYXQyTBkVApTN66MhBIYjzkub5205J +bVQmOV37AKklY1DhwQJAA1wos0cYxro02edzatxd0DIR2r4qqOqLkw6BhYHhq6HJ +ZvIcQkHqdSXzdETFc01I1znDGGIrJHcnvKWgBPoEUg== +-----END RSA PRIVATE KEY----- diff --git a/http-proxy-agent/test/ssl-cert-snakeoil.pem b/http-proxy-agent/test/ssl-cert-snakeoil.pem new file mode 100644 index 0000000..b115a5e --- /dev/null +++ b/http-proxy-agent/test/ssl-cert-snakeoil.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIB1TCCAT4CCQDV5mPlzm9+izANBgkqhkiG9w0BAQUFADAvMS0wKwYDVQQDEyQ3 +NTI3YmQ3Ny1hYjNlLTQ3NGItYWNlNy1lZWQ2MDUzOTMxZTcwHhcNMTUwNzA2MjI0 +NTA3WhcNMjUwNzAzMjI0NTA3WjAvMS0wKwYDVQQDEyQ3NTI3YmQ3Ny1hYjNlLTQ3 +NGItYWNlNy1lZWQ2MDUzOTMxZTcwgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGB +ALNRHEirN6vQAIBtf+Ud2yfgZ308sqt/sR4Nh8h1XQdko8PxYOtsHHb98G7Q5bay +OfO/9JVlsnQMC48T+nT55PSpvaoLXSLP2JMPuuJt3uDGGKtVvzJvd8GNezFzxXyn +g3LUty81KoKPBRsmp1GiB+gl5OKo8znzPmw3O6/mKruLAgMBAAEwDQYJKoZIhvcN +AQEFBQADgYEACzoHUF8UV2Z6541Q2wKEA0UFUzmUjf/E1XwBO+1P15ZZ64uw34B4 +1RwMPtAo9RY/PmICTWtNxWGxkzwb2JtDWtnxVER/lF8k2XcXPE76fxTHJF/BKk9J +QU8OTD1dd9gHCBviQB9TqntRZ5X7axjtuWjb2umY+owBYzAHZkp1HKI= +-----END CERTIFICATE----- diff --git a/http-proxy-agent/test/test.js b/http-proxy-agent/test/test.js new file mode 100644 index 0000000..fdd7782 --- /dev/null +++ b/http-proxy-agent/test/test.js @@ -0,0 +1,389 @@ +/** + * Module dependencies. + */ + +const fs = require('fs'); +const net = require('net'); +const url = require('url'); +const http = require('http'); +const https = require('https'); +const assert = require('assert'); +const Proxy = require('proxy'); +const { Agent } = require('agent-base'); +const { HttpProxyAgent } = require('../'); + +const sleep = n => new Promise(r => setTimeout(r, n)); + +describe('HttpProxyAgent', function() { + let server; + let serverPort; + + let proxy; + let proxyPort; + + let sslProxy; + let sslProxyPort; + + before(function(done) { + // setup HTTP proxy server + proxy = Proxy(); + proxy.listen(function() { + proxyPort = proxy.address().port; + done(); + }); + }); + + before(function(done) { + // setup target HTTP server + server = http.createServer(); + server.listen(function() { + serverPort = server.address().port; + done(); + }); + }); + + beforeEach(function() { + server.removeAllListeners('request'); + }); + + before(function(done) { + // setup SSL HTTP proxy server + let options = { + key: fs.readFileSync(`${__dirname}/ssl-cert-snakeoil.key`), + cert: fs.readFileSync(`${__dirname}/ssl-cert-snakeoil.pem`) + }; + sslProxy = Proxy(https.createServer(options)); + sslProxy.listen(function() { + sslProxyPort = sslProxy.address().port; + done(); + }); + }); + + // shut down test HTTP server + after(function(done) { + proxy.once('close', function() { + done(); + }); + proxy.close(); + }); + + after(function(done) { + server.once('close', function() { + done(); + }); + server.close(); + }); + + after(function(done) { + sslProxy.once('close', function() { + done(); + }); + sslProxy.close(); + }); + + describe('constructor', function() { + it('should throw an Error if no "proxy" argument is given', function() { + assert.throws(function() { + new HttpProxyAgent(); + }); + }); + it('should accept a "string" proxy argument', function() { + let agent = new HttpProxyAgent(`http://127.0.0.1:${proxyPort}`); + assert.equal('127.0.0.1', agent.proxy.host); + assert.equal(proxyPort, agent.proxy.port); + }); + it('should accept a `url.parse()` result object argument', function() { + let opts = url.parse(`http://127.0.0.1:${proxyPort}`); + let agent = new HttpProxyAgent(opts); + assert.equal('127.0.0.1', agent.proxy.host); + assert.equal(proxyPort, agent.proxy.port); + }); + it('should set a `defaultPort` property', function() { + let opts = url.parse(`http://127.0.0.1:${proxyPort}`); + let agent = new HttpProxyAgent(opts); + assert.equal(80, agent.defaultPort); + }); + describe('secureProxy', function() { + it('should default to `false`', function() { + let agent = new HttpProxyAgent({ port: proxyPort }); + assert.equal(false, agent.secureProxy); + }); + it('should be `false` when "http:" protocol is used', function() { + let agent = new HttpProxyAgent({ + port: proxyPort, + protocol: 'http:' + }); + assert.equal(false, agent.secureProxy); + }); + it('should be `true` when "https:" protocol is used', function() { + let agent = new HttpProxyAgent({ + port: proxyPort, + protocol: 'https:' + }); + assert.equal(true, agent.secureProxy); + }); + it('should be `true` when "https" protocol is used', function() { + let agent = new HttpProxyAgent({ + port: proxyPort, + protocol: 'https' + }); + assert.equal(true, agent.secureProxy); + }); + it('should support a `defaultPort` option', function() { + let agent = new HttpProxyAgent({ + port: proxyPort, + secureProxy: true + }); + assert.equal(true, agent.secureProxy); + }); + }); + }); + + describe('"http" module', function() { + it('should work over an HTTP proxy', function(done) { + // set HTTP "request" event handler for this test + server.once('request', function(req, res) { + res.end(JSON.stringify(req.headers)); + }); + + let proxy = + process.env.HTTP_PROXY || + process.env.http_proxy || + `http://127.0.0.1:${proxyPort}`; + let agent = new HttpProxyAgent(proxy); + + let opts = url.parse(`http://127.0.0.1:${serverPort}`); + opts.agent = agent; + + http.get(opts, function(res) { + let data = ''; + res.setEncoding('utf8'); + res.on('data', function(b) { + data += b; + }); + res.on('end', function() { + data = JSON.parse(data); + assert.equal(`127.0.0.1:${serverPort}`, data.host); + assert('via' in data); + done(); + }); + }); + }); + it('should work over an HTTPS proxy', function(done) { + // set HTTP "request" event handler for this test + server.once('request', function(req, res) { + res.end(JSON.stringify(req.headers)); + }); + + let proxy = + process.env.HTTPS_PROXY || + process.env.https_proxy || + `https://127.0.0.1:${sslProxyPort}`; + proxy = url.parse(proxy); + proxy.rejectUnauthorized = false; + let agent = new HttpProxyAgent(proxy); + assert.equal(true, agent.secureProxy); + + let opts = url.parse(`http://127.0.0.1:${serverPort}`); + opts.agent = agent; + + http.get(opts, function(res) { + let data = ''; + res.setEncoding('utf8'); + res.on('data', function(b) { + data += b; + }); + res.on('end', function() { + data = JSON.parse(data); + assert.equal(`127.0.0.1:${serverPort}`, data.host); + assert('via' in data); + done(); + }); + }); + }); + it('should proxy the query string of the request path', function(done) { + // set HTTP "request" event handler for this test + server.once('request', function(req, res) { + res.end( + JSON.stringify({ + url: req.url + }) + ); + }); + + let proxy = + process.env.HTTP_PROXY || + process.env.http_proxy || + `http://127.0.0.1:${proxyPort}`; + let agent = new HttpProxyAgent(proxy); + + let opts = url.parse( + `http://127.0.0.1:${serverPort}/test?foo=bar&1=2` + ); + opts.agent = agent; + + http.get(opts, function(res) { + let data = ''; + res.setEncoding('utf8'); + res.on('data', function(b) { + data += b; + }); + res.on('end', function() { + data = JSON.parse(data); + assert.equal('/test?foo=bar&1=2', data.url); + done(); + }); + }); + }); + it('should receive the 407 authorization code on the `http.ClientResponse`', function(done) { + // set a proxy authentication function for this test + proxy.authenticate = function(req, fn) { + // reject all requests + fn(null, false); + }; + + let proxyUri = + process.env.HTTP_PROXY || + process.env.http_proxy || + `http://127.0.0.1:${proxyPort}`; + let agent = new HttpProxyAgent(proxyUri); + + let opts = {}; + // `host` and `port` don't really matter since the proxy will reject anyways + opts.host = '127.0.0.1'; + opts.port = 80; + opts.agent = agent; + + http.get(opts, function(res) { + assert.equal(407, res.statusCode); + assert('proxy-authenticate' in res.headers); + delete proxy.authenticate; + done(); + }); + }); + it('should send the "Proxy-Authorization" request header', function(done) { + // set a proxy authentication function for this test + proxy.authenticate = function(req, fn) { + // username:password is "foo:bar" + fn( + null, + req.headers['proxy-authorization'] == 'Basic Zm9vOmJhcg==' + ); + }; + + // set HTTP "request" event handler for this test + server.once('request', function(req, res) { + res.end(JSON.stringify(req.headers)); + }); + + let proxyUri = + process.env.HTTP_PROXY || + process.env.http_proxy || + `http://127.0.0.1:${proxyPort}`; + let proxyOpts = url.parse(proxyUri); + proxyOpts.auth = 'foo:bar'; + let agent = new HttpProxyAgent(proxyOpts); + + let opts = url.parse(`http://127.0.0.1:${serverPort}`); + opts.agent = agent; + + http.get(opts, function(res) { + let data = ''; + res.setEncoding('utf8'); + res.on('data', function(b) { + data += b; + }); + res.on('end', function() { + data = JSON.parse(data); + assert.equal(`127.0.0.1:${serverPort}`, data.host); + assert('via' in data); + delete proxy.authenticate; + done(); + }); + }); + }); + it('should emit an "error" event on the `http.ClientRequest` if the proxy does not exist', function(done) { + // port 4 is a reserved, but "unassigned" port + let proxyUri = 'http://127.0.0.1:4'; + let agent = new HttpProxyAgent(proxyUri); + + let opts = url.parse('http://nodejs.org'); + opts.agent = agent; + + let req = http.get(opts); + req.once('error', function(err) { + assert.equal('ECONNREFUSED', err.code); + req.abort(); + done(); + }); + }); + it('should work after the first tick of the `http.ClientRequest` instance', function(done) { + // set HTTP "request" event handler for this test + server.once('request', function(req, res) { + res.end(JSON.stringify(req.url)); + }); + + let proxy = + process.env.HTTP_PROXY || + process.env.http_proxy || + `http://127.0.0.1:${proxyPort}`; + let httpProxyAgent = new HttpProxyAgent(proxy); + + // Defer the "connect()" function logic, since calling `req.end()` + // before the socket is returned causes the HTTP header to be + // generated *before* `HttpProxyAgent` can patch the `req.path` + // property, making the header incorrect. + const sleepAgent = new Agent((req, opts) => { + assert.equal(opts.secureEndpoint, false); + assert.equal(opts.protocol, 'http:'); + assert(!req._header); + return sleep(10).then(() => { + assert.equal(typeof req._header, 'string'); + return httpProxyAgent; + }); + }); + + const opts = url.parse(`http://127.0.0.1:${serverPort}/test`); + opts.agent = sleepAgent; + + http.get(opts, function(res) { + let data = ''; + res.setEncoding('utf8'); + res.on('data', function(b) { + data += b; + }); + res.on('end', function() { + data = JSON.parse(data); + assert.equal('/test', data); + done(); + }); + }); + }); + it('should not send a port number for the default port', function(done) { + server.once('request', function(req, res) { + res.end(JSON.stringify(req.headers)); + }); + let proxy = + process.env.HTTP_PROXY || + process.env.http_proxy || + `http://127.0.0.1:${proxyPort}`; + proxy = url.parse(proxy); + let agent = new HttpProxyAgent(proxy); + agent.defaultPort = serverPort; + let opts = url.parse(`http://127.0.0.1:${serverPort}`); + opts.agent = agent; + http.get(opts, function(res) { + let data = ''; + res.setEncoding('utf8'); + res.on('data', function(b) { + data += b; + }); + res.on('end', function() { + data = JSON.parse(data); + assert.equal('127.0.0.1', data.host); + done(); + }); + }); + }); + }); +}); diff --git a/http-proxy-agent/tsconfig.json b/http-proxy-agent/tsconfig.json new file mode 100644 index 0000000..63692a7 --- /dev/null +++ b/http-proxy-agent/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "module": "CommonJS", + "target": "es2015", + "esModuleInterop": true, + "lib": ["esnext"], + "outDir": "dist", + "sourceMap": true, + "declaration": true, + "typeRoots": [ + "./@types", + "./node_modules/@types" + ] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7872bdf --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "https-proxy-agent", + "version": "5.0.0", + "description": "An HTTP(s) proxy `http.Agent` implementation for HTTPS", + "main": "dist/index", + "types": "dist/index", + "files": [ + "dist" + ], + "scripts": { + "prebuild": "rimraf dist", + "build": "tsc", + "test": "mocha --reporter spec", + "test-lint": "eslint src --ext .js,.ts", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git://github.com/TooTallNate/node-https-proxy-agent.git" + }, + "keywords": [ + "https", + "proxy", + "endpoint", + "agent" + ], + "author": "Nathan Rajlich (http://n8.io/)", + "license": "MIT", + "bugs": { + "url": "https://github.com/TooTallNate/node-https-proxy-agent/issues" + }, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "devDependencies": { + "@types/debug": "4", + "@types/node": "^12.12.11", + "@typescript-eslint/eslint-plugin": "1.6.0", + "@typescript-eslint/parser": "1.1.0", + "eslint": "5.16.0", + "eslint-config-airbnb": "17.1.0", + "eslint-config-prettier": "4.1.0", + "eslint-import-resolver-typescript": "1.1.1", + "eslint-plugin-import": "2.16.0", + "eslint-plugin-jsx-a11y": "6.2.1", + "eslint-plugin-react": "7.12.4", + "mocha": "^6.2.2", + "proxy": "1", + "rimraf": "^3.0.0", + "typescript": "^3.5.3" + }, + "engines": { + "node": ">= 6" + } +} diff --git a/src/agent.ts b/src/agent.ts new file mode 100644 index 0000000..7098c8a --- /dev/null +++ b/src/agent.ts @@ -0,0 +1,215 @@ +import net from 'net'; +import tls from 'tls'; +import url from 'url'; +import assert from 'assert'; +import createDebug from 'debug'; +import { OutgoingHttpHeaders } from 'http'; +import { Agent, ClientRequest, RequestOptions } from 'agent-base'; +import { HttpsProxyAgentOptions } from '.'; +import parseProxyResponse from './parse-proxy-response'; + +const debug = createDebug('https-proxy-agent:agent'); + +/** + * The `HttpsProxyAgent` implements an HTTP Agent subclass that connects to + * the specified "HTTP(s) proxy server" in order to proxy HTTPS requests. + * + * Outgoing HTTP requests are first tunneled through the proxy server using the + * `CONNECT` HTTP request method to establish a connection to the proxy server, + * and then the proxy server connects to the destination target and issues the + * HTTP request from the proxy server. + * + * `https:` requests have their socket connection upgraded to TLS once + * the connection to the proxy server has been established. + * + * @api public + */ +export default class HttpsProxyAgent extends Agent { + private secureProxy: boolean; + private proxy: HttpsProxyAgentOptions; + + constructor(_opts: string | HttpsProxyAgentOptions) { + let opts: HttpsProxyAgentOptions; + if (typeof _opts === 'string') { + opts = url.parse(_opts); + } else { + opts = _opts; + } + if (!opts) { + throw new Error( + 'an HTTP(S) proxy server `host` and `port` must be specified!' + ); + } + debug('creating new HttpsProxyAgent instance: %o', opts); + super(opts); + + const proxy: HttpsProxyAgentOptions = { ...opts }; + + // If `true`, then connect to the proxy server over TLS. + // Defaults to `false`. + this.secureProxy = opts.secureProxy || isHTTPS(proxy.protocol); + + // Prefer `hostname` over `host`, and set the `port` if needed. + proxy.host = proxy.hostname || proxy.host; + if (typeof proxy.port === 'string') { + proxy.port = parseInt(proxy.port, 10); + } + if (!proxy.port && proxy.host) { + proxy.port = this.secureProxy ? 443 : 80; + } + + // ALPN is supported by Node.js >= v5. + // attempt to negotiate http/1.1 for proxy servers that support http/2 + if (this.secureProxy && !('ALPNProtocols' in proxy)) { + proxy.ALPNProtocols = ['http 1.1']; + } + + if (proxy.host && proxy.path) { + // If both a `host` and `path` are specified then it's most likely + // the result of a `url.parse()` call... we need to remove the + // `path` portion so that `net.connect()` doesn't attempt to open + // that as a Unix socket file. + delete proxy.path; + delete proxy.pathname; + } + + this.proxy = proxy; + } + + /** + * Called when the node-core HTTP client library is creating a + * new HTTP request. + * + * @api protected + */ + async callback( + req: ClientRequest, + opts: RequestOptions + ): Promise { + const { proxy, secureProxy } = this; + + // Create a socket connection to the proxy server. + let socket: net.Socket; + if (secureProxy) { + debug('Creating `tls.Socket`: %o', proxy); + socket = tls.connect(proxy as tls.ConnectionOptions); + } else { + debug('Creating `net.Socket`: %o', proxy); + socket = net.connect(proxy as net.NetConnectOpts); + } + + const headers: OutgoingHttpHeaders = { ...proxy.headers }; + const hostname = `${opts.host}:${opts.port}`; + let payload = `CONNECT ${hostname} HTTP/1.1\r\n`; + + // Inject the `Proxy-Authorization` header if necessary. + if (proxy.auth) { + headers['Proxy-Authorization'] = `Basic ${Buffer.from( + proxy.auth + ).toString('base64')}`; + } + + // The `Host` header should only include the port + // number when it is not the default port. + let { host, port, secureEndpoint } = opts; + if (!isDefaultPort(port, secureEndpoint)) { + host += `:${port}`; + } + headers.Host = host; + + headers.Connection = 'close'; + for (const name of Object.keys(headers)) { + payload += `${name}: ${headers[name]}\r\n`; + } + + const proxyResponsePromise = parseProxyResponse(socket); + + socket.write(`${payload}\r\n`); + + const { + statusCode, + buffered + } = await proxyResponsePromise; + + if (statusCode === 200) { + req.once('socket', resume); + + if (opts.secureEndpoint) { + const servername = opts.servername || opts.host; + if (!servername) { + throw new Error('Could not determine "servername"'); + } + // The proxy is connecting to a TLS server, so upgrade + // this socket connection to a TLS connection. + debug('Upgrading socket connection to TLS'); + return tls.connect({ + ...omit(opts, 'host', 'hostname', 'path', 'port'), + socket, + servername + }); + } + + return socket; + } + + // Some other status code that's not 200... need to re-play the HTTP + // header "data" events onto the socket once the HTTP machinery is + // attached so that the node core `http` can parse and handle the + // error status code. + + // Close the original socket, and a new "fake" socket is returned + // instead, so that the proxy doesn't get the HTTP request + // written to it (which may contain `Authorization` headers or other + // sensitive data). + // + // See: https://hackerone.com/reports/541502 + socket.destroy(); + + const fakeSocket = new net.Socket(); + fakeSocket.readable = true; + + // Need to wait for the "socket" event to re-play the "data" events. + req.once('socket', (s: net.Socket) => { + debug('replaying proxy buffer for failed request'); + assert(s.listenerCount('data') > 0); + + // Replay the "buffered" Buffer onto the fake `socket`, since at + // this point the HTTP module machinery has been hooked up for + // the user. + s.push(buffered); + s.push(null); + }); + + return fakeSocket; + } +} + +function resume(socket: net.Socket | tls.TLSSocket): void { + socket.resume(); +} + +function isDefaultPort(port: number, secure: boolean): boolean { + return Boolean((!secure && port === 80) || (secure && port === 443)); +} + +function isHTTPS(protocol?: string | null): boolean { + return typeof protocol === 'string' ? /^https:?$/i.test(protocol) : false; +} + +function omit( + obj: T, + ...keys: K +): { + [K2 in Exclude]: T[K2]; +} { + const ret = {} as { + [K in keyof typeof obj]: (typeof obj)[K]; + }; + let key: keyof typeof obj; + for (key in obj) { + if (!keys.includes(key)) { + ret[key] = obj[key]; + } + } + return ret; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9ef0d5d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,39 @@ +import net from 'net'; +import tls from 'tls'; +import { Url } from 'url'; +import { AgentOptions } from 'agent-base'; +import { OutgoingHttpHeaders } from 'http'; +import _HttpsProxyAgent from './agent'; + +function createHttpsProxyAgent( + opts: string | createHttpsProxyAgent.HttpsProxyAgentOptions +): _HttpsProxyAgent { + return new _HttpsProxyAgent(opts); +} + +namespace createHttpsProxyAgent { + interface BaseHttpsProxyAgentOptions { + headers?: OutgoingHttpHeaders; + secureProxy?: boolean; + host?: string | null; + path?: string | null; + port?: string | number | null; + } + + export interface HttpsProxyAgentOptions + extends AgentOptions, + BaseHttpsProxyAgentOptions, + Partial< + Omit< + Url & net.NetConnectOpts & tls.ConnectionOptions, + keyof BaseHttpsProxyAgentOptions + > + > {} + + export type HttpsProxyAgent = _HttpsProxyAgent; + export const HttpsProxyAgent = _HttpsProxyAgent; + + createHttpsProxyAgent.prototype = _HttpsProxyAgent.prototype; +} + +export = createHttpsProxyAgent; diff --git a/src/parse-proxy-response.ts b/src/parse-proxy-response.ts new file mode 100644 index 0000000..6791775 --- /dev/null +++ b/src/parse-proxy-response.ts @@ -0,0 +1,82 @@ +import createDebug from 'debug'; +import { Readable } from 'stream'; + +const debug = createDebug('https-proxy-agent:parse-proxy-response'); + +export interface ProxyResponse { + statusCode: number; + buffered: Buffer; +} + +export default function parseProxyResponse( + socket: Readable +): Promise { + return new Promise((resolve, reject) => { + // we need to buffer any HTTP traffic that happens with the proxy before we get + // the CONNECT response, so that if the response is anything other than an "200" + // response code, then we can re-play the "data" events on the socket once the + // HTTP parser is hooked up... + let buffersLength = 0; + const buffers: Buffer[] = []; + + function read() { + const b = socket.read(); + if (b) ondata(b); + else socket.once('readable', read); + } + + function cleanup() { + socket.removeListener('end', onend); + socket.removeListener('error', onerror); + socket.removeListener('close', onclose); + socket.removeListener('readable', read); + } + + function onclose(err?: Error) { + debug('onclose had error %o', err); + } + + function onend() { + debug('onend'); + } + + function onerror(err: Error) { + cleanup(); + debug('onerror %o', err); + reject(err); + } + + function ondata(b: Buffer) { + buffers.push(b); + buffersLength += b.length; + + const buffered = Buffer.concat(buffers, buffersLength); + const endOfHeaders = buffered.indexOf('\r\n\r\n'); + + if (endOfHeaders === -1) { + // keep buffering + debug('have not received end of HTTP headers yet...'); + read(); + return; + } + + const firstLine = buffered.toString( + 'ascii', + 0, + buffered.indexOf('\r\n') + ); + const statusCode = +firstLine.split(' ')[1]; + debug('got proxy server response: %o', firstLine); + resolve({ + statusCode, + buffered + }); + } + + socket.on('error', onerror); + socket.on('close', onclose); + socket.on('end', onend); + + read(); + }); +} diff --git a/test/ssl-cert-snakeoil.key b/test/ssl-cert-snakeoil.key new file mode 100644 index 0000000..fd12501 --- /dev/null +++ b/test/ssl-cert-snakeoil.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICWwIBAAKBgQCzURxIqzer0ACAbX/lHdsn4Gd9PLKrf7EeDYfIdV0HZKPD8WDr +bBx2/fBu0OW2sjnzv/SVZbJ0DAuPE/p0+eT0qb2qC10iz9iTD7ribd7gxhirVb8y +b3fBjXsxc8V8p4Ny1LcvNSqCjwUbJqdRogfoJeTiqPM58z5sNzuv5iq7iwIDAQAB +AoGAPMQy4olrP0UotlzlJ36bowLP70ffgHCwU+/f4NWs5fF78c3du0oSx1w820Dd +Z7E0JF8bgnlJJTxjumPZz0RUCugrEHBKJmzEz3cxF5E3+7NvteZcjKn9D67RrM5x +1/uSZ9cqKE9cYvY4fSuHx18diyZ4axR/wB1Pea2utjjDM+ECQQDb9ZbmmaWMiRpQ +5Up+loxP7BZNPsEVsm+DVJmEFbaFgGfncWBqSIqnPNjMwTwj0OigTwCAEGPkfRVW +T0pbYWCxAkEA0LK7SCTwzyDmhASUalk0x+3uCAA6ryFdwJf/wd8TRAvVOmkTEldX +uJ7ldLvfrONYO3v56uKTU/SoNdZYzKtO+wJAX2KM4ctXYy5BXztPpr2acz4qHa1N +Bh+vBAC34fOYhyQ76r3b1btHhWZ5jbFuZwm9F2erC94Ps5IaoqcX07DSwQJAPKGw +h2U0EPkd/3zVIZCJJQya+vgWFIs9EZcXVtvYXQyTBkVApTN66MhBIYjzkub5205J +bVQmOV37AKklY1DhwQJAA1wos0cYxro02edzatxd0DIR2r4qqOqLkw6BhYHhq6HJ +ZvIcQkHqdSXzdETFc01I1znDGGIrJHcnvKWgBPoEUg== +-----END RSA PRIVATE KEY----- diff --git a/test/ssl-cert-snakeoil.pem b/test/ssl-cert-snakeoil.pem new file mode 100644 index 0000000..b115a5e --- /dev/null +++ b/test/ssl-cert-snakeoil.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIB1TCCAT4CCQDV5mPlzm9+izANBgkqhkiG9w0BAQUFADAvMS0wKwYDVQQDEyQ3 +NTI3YmQ3Ny1hYjNlLTQ3NGItYWNlNy1lZWQ2MDUzOTMxZTcwHhcNMTUwNzA2MjI0 +NTA3WhcNMjUwNzAzMjI0NTA3WjAvMS0wKwYDVQQDEyQ3NTI3YmQ3Ny1hYjNlLTQ3 +NGItYWNlNy1lZWQ2MDUzOTMxZTcwgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGB +ALNRHEirN6vQAIBtf+Ud2yfgZ308sqt/sR4Nh8h1XQdko8PxYOtsHHb98G7Q5bay +OfO/9JVlsnQMC48T+nT55PSpvaoLXSLP2JMPuuJt3uDGGKtVvzJvd8GNezFzxXyn +g3LUty81KoKPBRsmp1GiB+gl5OKo8znzPmw3O6/mKruLAgMBAAEwDQYJKoZIhvcN +AQEFBQADgYEACzoHUF8UV2Z6541Q2wKEA0UFUzmUjf/E1XwBO+1P15ZZ64uw34B4 +1RwMPtAo9RY/PmICTWtNxWGxkzwb2JtDWtnxVER/lF8k2XcXPE76fxTHJF/BKk9J +QU8OTD1dd9gHCBviQB9TqntRZ5X7axjtuWjb2umY+owBYzAHZkp1HKI= +-----END CERTIFICATE----- diff --git a/test/test.js b/test/test.js new file mode 100644 index 0000000..36fd26f --- /dev/null +++ b/test/test.js @@ -0,0 +1,410 @@ +/** + * Module dependencies. + */ + +let fs = require('fs'); +let url = require('url'); +let http = require('http'); +let https = require('https'); +let assert = require('assert'); +let Proxy = require('proxy'); +let HttpsProxyAgent = require('../'); + +describe('HttpsProxyAgent', function() { + let server; + let serverPort; + + let sslServer; + let sslServerPort; + + let proxy; + let proxyPort; + + let sslProxy; + let sslProxyPort; + + before(function(done) { + // setup target HTTP server + server = http.createServer(); + server.listen(function() { + serverPort = server.address().port; + done(); + }); + }); + + before(function(done) { + // setup HTTP proxy server + proxy = Proxy(); + proxy.listen(function() { + proxyPort = proxy.address().port; + done(); + }); + }); + + before(function(done) { + // setup target HTTPS server + let options = { + key: fs.readFileSync(`${__dirname}/ssl-cert-snakeoil.key`), + cert: fs.readFileSync(`${__dirname}/ssl-cert-snakeoil.pem`) + }; + sslServer = https.createServer(options); + sslServer.listen(function() { + sslServerPort = sslServer.address().port; + done(); + }); + }); + + before(function(done) { + // setup SSL HTTP proxy server + let options = { + key: fs.readFileSync(`${__dirname}/ssl-cert-snakeoil.key`), + cert: fs.readFileSync(`${__dirname}/ssl-cert-snakeoil.pem`) + }; + sslProxy = Proxy(https.createServer(options)); + sslProxy.listen(function() { + sslProxyPort = sslProxy.address().port; + done(); + }); + }); + + // shut down test HTTP server + after(function(done) { + server.once('close', function() { + done(); + }); + server.close(); + }); + + after(function(done) { + proxy.once('close', function() { + done(); + }); + proxy.close(); + }); + + after(function(done) { + sslServer.once('close', function() { + done(); + }); + sslServer.close(); + }); + + after(function(done) { + sslProxy.once('close', function() { + done(); + }); + sslProxy.close(); + }); + + describe('constructor', function() { + it('should throw an Error if no "proxy" argument is given', function() { + assert.throws(function() { + new HttpsProxyAgent(); + }); + }); + it('should accept a "string" proxy argument', function() { + let agent = new HttpsProxyAgent(`http://localhost:${proxyPort}`); + assert.equal('localhost', agent.proxy.host); + assert.equal(proxyPort, agent.proxy.port); + }); + it('should accept a `url.parse()` result object argument', function() { + let opts = url.parse(`http://localhost:${proxyPort}`); + let agent = new HttpsProxyAgent(opts); + assert.equal('localhost', agent.proxy.host); + assert.equal(proxyPort, agent.proxy.port); + }); + describe('secureProxy', function() { + it('should default to `false`', function() { + let agent = new HttpsProxyAgent({ port: proxyPort }); + assert.equal(false, agent.secureProxy); + }); + it('should be `false` when "http:" protocol is used', function() { + let agent = new HttpsProxyAgent({ + port: proxyPort, + protocol: 'http:' + }); + assert.equal(false, agent.secureProxy); + }); + it('should be `true` when "https:" protocol is used', function() { + let agent = new HttpsProxyAgent({ + port: proxyPort, + protocol: 'https:' + }); + assert.equal(true, agent.secureProxy); + }); + it('should be `true` when "https" protocol is used', function() { + let agent = new HttpsProxyAgent({ + port: proxyPort, + protocol: 'https' + }); + assert.equal(true, agent.secureProxy); + }); + }); + }); + + describe('"http" module', function() { + beforeEach(function() { + delete proxy.authenticate; + }); + + it('should work over an HTTP proxy', function(done) { + server.once('request', function(req, res) { + res.end(JSON.stringify(req.headers)); + }); + + let proxy = + process.env.HTTP_PROXY || + process.env.http_proxy || + `http://localhost:${proxyPort}`; + let agent = new HttpsProxyAgent(proxy); + + let opts = url.parse(`http://localhost:${serverPort}`); + opts.agent = agent; + + let req = http.get(opts, function(res) { + let data = ''; + res.setEncoding('utf8'); + res.on('data', function(b) { + data += b; + }); + res.on('end', function() { + data = JSON.parse(data); + assert.equal(`localhost:${serverPort}`, data.host); + done(); + }); + }); + req.once('error', done); + }); + it('should work over an HTTPS proxy', function(done) { + server.once('request', function(req, res) { + res.end(JSON.stringify(req.headers)); + }); + + let proxy = + process.env.HTTPS_PROXY || + process.env.https_proxy || + `https://localhost:${sslProxyPort}`; + proxy = url.parse(proxy); + proxy.rejectUnauthorized = false; + let agent = new HttpsProxyAgent(proxy); + + let opts = url.parse(`http://localhost:${serverPort}`); + opts.agent = agent; + + http.get(opts, function(res) { + let data = ''; + res.setEncoding('utf8'); + res.on('data', function(b) { + data += b; + }); + res.on('end', function() { + data = JSON.parse(data); + assert.equal(`localhost:${serverPort}`, data.host); + done(); + }); + }); + }); + it('should receive the 407 authorization code on the `http.ClientResponse`', function(done) { + // set a proxy authentication function for this test + proxy.authenticate = function(req, fn) { + // reject all requests + fn(null, false); + }; + + let proxyUri = + process.env.HTTP_PROXY || + process.env.http_proxy || + `http://localhost:${proxyPort}`; + let agent = new HttpsProxyAgent(proxyUri); + + let opts = {}; + // `host` and `port` don't really matter since the proxy will reject anyways + opts.host = 'localhost'; + opts.port = 80; + opts.agent = agent; + + let req = http.get(opts, function(res) { + assert.equal(407, res.statusCode); + assert('proxy-authenticate' in res.headers); + done(); + }); + }); + it('should not error if the proxy responds with 407 and the request is aborted', function(done) { + proxy.authenticate = function(req, fn) { + fn(null, false); + }; + + const proxyUri = + process.env.HTTP_PROXY || + process.env.http_proxy || + `http://localhost:${proxyPort}`; + + const req = http.get( + { + agent: new HttpsProxyAgent(proxyUri) + }, + function(res) { + assert.equal(407, res.statusCode); + req.abort(); + } + ); + + req.on('abort', done); + }); + it('should emit an "end" event on the `http.IncomingMessage` if the proxy responds with non-200 status code', function(done) { + proxy.authenticate = function(req, fn) { + fn(null, false); + }; + + const proxyUri = + process.env.HTTP_PROXY || + process.env.http_proxy || + `http://localhost:${proxyPort}`; + + const req = http.get( + { + agent: new HttpsProxyAgent(proxyUri) + }, + function(res) { + assert.equal(407, res.statusCode); + + res.resume(); + res.on('end', done); + } + ); + }); + it('should emit an "error" event on the `http.ClientRequest` if the proxy does not exist', function(done) { + // port 4 is a reserved, but "unassigned" port + let proxyUri = 'http://localhost:4'; + let agent = new HttpsProxyAgent(proxyUri); + + let opts = url.parse('http://nodejs.org'); + opts.agent = agent; + + let req = http.get(opts); + req.once('error', function(err) { + assert.equal('ECONNREFUSED', err.code); + req.abort(); + done(); + }); + }); + + it('should allow custom proxy "headers"', function(done) { + server.once('connect', function(req, socket, head) { + assert.equal('CONNECT', req.method); + assert.equal('bar', req.headers.foo); + socket.destroy(); + done(); + }); + + let uri = `http://localhost:${serverPort}`; + let proxyOpts = url.parse(uri); + proxyOpts.headers = { + Foo: 'bar' + }; + let agent = new HttpsProxyAgent(proxyOpts); + + let opts = {}; + // `host` and `port` don't really matter since the proxy will reject anyways + opts.host = 'localhost'; + opts.port = 80; + opts.agent = agent; + + http.get(opts); + }); + }); + + describe('"https" module', function() { + it('should work over an HTTP proxy', function(done) { + sslServer.once('request', function(req, res) { + res.end(JSON.stringify(req.headers)); + }); + + let proxy = + process.env.HTTP_PROXY || + process.env.http_proxy || + `http://localhost:${proxyPort}`; + let agent = new HttpsProxyAgent(proxy); + + let opts = url.parse(`https://localhost:${sslServerPort}`); + opts.rejectUnauthorized = false; + opts.agent = agent; + + https.get(opts, function(res) { + let data = ''; + res.setEncoding('utf8'); + res.on('data', function(b) { + data += b; + }); + res.on('end', function() { + data = JSON.parse(data); + assert.equal(`localhost:${sslServerPort}`, data.host); + done(); + }); + }); + }); + + it('should work over an HTTPS proxy', function(done) { + sslServer.once('request', function(req, res) { + res.end(JSON.stringify(req.headers)); + }); + + let proxy = + process.env.HTTPS_PROXY || + process.env.https_proxy || + `https://localhost:${sslProxyPort}`; + proxy = url.parse(proxy); + proxy.rejectUnauthorized = false; + let agent = new HttpsProxyAgent(proxy); + + let opts = url.parse(`https://localhost:${sslServerPort}`); + opts.agent = agent; + opts.rejectUnauthorized = false; + + https.get(opts, function(res) { + let data = ''; + res.setEncoding('utf8'); + res.on('data', function(b) { + data += b; + }); + res.on('end', function() { + data = JSON.parse(data); + assert.equal(`localhost:${sslServerPort}`, data.host); + done(); + }); + }); + }); + + it('should not send a port number for the default port', function(done) { + sslServer.once('request', function(req, res) { + res.end(JSON.stringify(req.headers)); + }); + + let proxy = + process.env.HTTPS_PROXY || + process.env.https_proxy || + `https://localhost:${sslProxyPort}`; + proxy = url.parse(proxy); + proxy.rejectUnauthorized = false; + let agent = new HttpsProxyAgent(proxy); + agent.defaultPort = sslServerPort; + + let opts = url.parse(`https://localhost:${sslServerPort}`); + opts.agent = agent; + opts.rejectUnauthorized = false; + + https.get(opts, function(res) { + let data = ''; + res.setEncoding('utf8'); + res.on('data', function(b) { + data += b; + }); + res.on('end', function() { + data = JSON.parse(data); + assert.equal('localhost', data.host); + done(); + }); + }); + }); + }); +}); diff --git a/tootallnate-once/.editorconfig b/tootallnate-once/.editorconfig new file mode 100644 index 0000000..12b4b9a --- /dev/null +++ b/tootallnate-once/.editorconfig @@ -0,0 +1,37 @@ +root = true + +[*] +indent_style = tab +indent_size = 4 +tab_width = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[{*.json,*.json.example,*.gyp,*.yml,*.yaml,*.workflow}] +indent_style = space +indent_size = 2 + +[{*.py,*.asm}] +indent_style = space + +[*.py] +indent_size = 4 + +[*.asm] +indent_size = 8 + +[*.md] +trim_trailing_whitespace = false + +# Ideal settings - some plugins might support these. +[*.js] +quote_type = single + +[{*.c,*.cc,*.h,*.hh,*.cpp,*.hpp,*.m,*.mm,*.mpp,*.js,*.java,*.go,*.rs,*.php,*.ng,*.jsx,*.ts,*.d,*.cs,*.swift}] +curly_bracket_next_line = false +spaces_around_operators = true +spaces_around_brackets = outside +# close enough to 1TB +indent_brace_style = K&R diff --git a/tootallnate-once/.github/workflows/test.yml b/tootallnate-once/.github/workflows/test.yml new file mode 100644 index 0000000..81018e0 --- /dev/null +++ b/tootallnate-once/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Node CI + +on: [push] + +jobs: + build: + name: Test Node.js ${{ matrix.node-version }} on ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node-version: [10.x, 12.x, 14.x, 16.x] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v1 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Print Node.js Version + run: node --version + + - name: Install Dependencies + run: npm install + env: + CI: true + + - name: Run "build" step + run: npm run build --if-present + env: + CI: true + + - name: Run tests + run: npm test + env: + CI: true diff --git a/tootallnate-once/.gitignore b/tootallnate-once/.gitignore new file mode 100644 index 0000000..38fc8fe --- /dev/null +++ b/tootallnate-once/.gitignore @@ -0,0 +1,6 @@ +/yarn.lock +/node_modules +/src/?.?s +/dist +/.vscode +/*.tgz diff --git a/tootallnate-once/LICENSE b/tootallnate-once/LICENSE new file mode 100644 index 0000000..c4c56a2 --- /dev/null +++ b/tootallnate-once/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Nathan Rajlich + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tootallnate-once/README.md b/tootallnate-once/README.md new file mode 100644 index 0000000..bc980fd --- /dev/null +++ b/tootallnate-once/README.md @@ -0,0 +1,93 @@ +# @tootallnate/once + +### Creates a Promise that waits for a single event + +## Installation + +Install with `npm`: + +```bash +$ npm install @tootallnate/once +``` + +## API + +### once(emitter: EventEmitter, name: string, opts?: OnceOptions): Promise<[...Args]> + +Creates a Promise that waits for event `name` to occur on `emitter`, and resolves +the promise with an array of the values provided to the event handler. If an +`error` event occurs before the event specified by `name`, then the Promise is +rejected with the error argument. + +```typescript +import once from '@tootallnate/once'; +import { EventEmitter } from 'events'; + +const emitter = new EventEmitter(); + +setTimeout(() => { + emitter.emit('foo', 'bar'); +}, 100); + +const [result] = await once(emitter, 'foo'); +console.log({ result }); +// { result: 'bar' } +``` + +#### Promise Strong Typing + +The main feature that this module provides over other "once" implementations is that +the Promise that is returned is _**strongly typed**_ based on the type of `emitter` +and the `name` of the event. Some examples are shown below. + +_The process "exit" event contains a single number for exit code:_ + +```typescript +const [code] = await once(process, 'exit'); +// ^ number +``` +_A child process "exit" event contains either an exit code or a signal:_ + +```typescript +const child = spawn('echo', []); +const [code, signal] = await once(child, 'exit'); +// ^ number | null +// ^ string | null +``` + +_A forked child process "message" event is type `any`, so you can cast the Promise directly:_ + +```typescript +const child = fork('file.js'); + +// With `await` +const [message, _]: [WorkerPayload, unknown] = await once(child, 'message'); + +// With Promise +const messagePromise: Promise<[WorkerPayload, unknown]> = once(child, 'message'); + +// Better yet would be to leave it as `any`, and validate the payload +// at runtime with i.e. `ajv` + `json-schema-to-typescript` +``` + +_If the TypeScript definition does not contain an overload for the specified event name, then the Promise will have type `unknown[]` and your code will need to narrow the result manually:_ + +```typescript +interface CustomEmitter extends EventEmitter { + on(name: 'foo', listener: (a: string, b: number) => void): this; +} + +const emitter: CustomEmitter = new EventEmitter(); + +// "foo" event is a defined overload, so it's properly typed +const fooPromise = once(emitter, 'foo'); +// ^ Promise<[a: string, b: number]> + +// "bar" event in not a defined overload, so it gets `unknown[]` +const barPromise = once(emitter, 'bar'); +// ^ Promise +``` + +### OnceOptions + +- `signal` - `AbortSignal` instance to unbind event handlers before the Promise has been fulfilled. diff --git a/tootallnate-once/package.json b/tootallnate-once/package.json new file mode 100644 index 0000000..18c4607 --- /dev/null +++ b/tootallnate-once/package.json @@ -0,0 +1,54 @@ +{ + "name": "@tootallnate/once", + "version": "3.0.0", + "description": "Creates a Promise that waits for a single event", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "src" + ], + "scripts": { + "prebuild": "rimraf dist", + "build": "tsc", + "test": "jest", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git://github.com/TooTallNate/once.git" + }, + "keywords": [], + "author": "Nathan Rajlich (http://n8.io/)", + "license": "MIT", + "bugs": { + "url": "https://github.com/TooTallNate/once/issues" + }, + "devDependencies": { + "@types/jest": "^27.0.2", + "@types/node": "^12.12.11", + "abort-controller": "^3.0.0", + "jest": "^27.2.1", + "rimraf": "^3.0.0", + "ts-jest": "^27.0.5", + "typescript": "^4.4.3" + }, + "engines": { + "node": ">= 10" + }, + "jest": { + "preset": "ts-jest", + "globals": { + "ts-jest": { + "diagnostics": false, + "isolatedModules": true + } + }, + "verbose": false, + "testEnvironment": "node", + "testMatch": [ + "/test/**/*.test.ts" + ] + } +} diff --git a/tootallnate-once/scripts/gen-overloaded-parameters.ts b/tootallnate-once/scripts/gen-overloaded-parameters.ts new file mode 100644 index 0000000..55ccc2e --- /dev/null +++ b/tootallnate-once/scripts/gen-overloaded-parameters.ts @@ -0,0 +1,28 @@ +/** + * This script generated a TypeScript file that defines + * a type based on this StackOverflow answer: + * https://stackoverflow.com/a/52761156/376773 + */ + +process.stdout.write('export type OverloadedParameters = \n'); + +const overloads = parseInt(process.argv[2], 10) || 5; + +for (let i = overloads; i > 0; i--) { + process.stdout.write(`\tT extends { `); + for (let j = 1; j <= i; j++) { + process.stdout.write( + `(...args: infer A${j}): any; ` + ); + } + process.stdout.write(`} ? `); + for (let j = 1; j <= i; j++) { + process.stdout.write(`A${j} `); + if (j < i) { + process.stdout.write(`| `); + } + } + process.stdout.write(`:\n`); +} + +process.stdout.write(`\tany;\n`); diff --git a/tootallnate-once/src/index.ts b/tootallnate-once/src/index.ts new file mode 100644 index 0000000..d11d725 --- /dev/null +++ b/tootallnate-once/src/index.ts @@ -0,0 +1,34 @@ +import { EventEmitter } from 'events'; +import { EventNames, EventListenerParameters, AbortSignal } from './types.js'; + +export interface OnceOptions { + signal?: AbortSignal; +} + +export default function once< + Emitter extends EventEmitter, + Event extends EventNames +>( + emitter: Emitter, + name: Event, + { signal }: OnceOptions = {} +): Promise> { + return new Promise((resolve, reject) => { + function cleanup() { + signal?.removeEventListener('abort', cleanup); + emitter.removeListener(name, onEvent); + emitter.removeListener('error', onError); + } + function onEvent(...args: EventListenerParameters) { + cleanup(); + resolve(args); + } + function onError(err: Error) { + cleanup(); + reject(err); + } + signal?.addEventListener('abort', cleanup); + emitter.on(name, onEvent); + emitter.on('error', onError); + }); +} diff --git a/tootallnate-once/src/overloaded-parameters.ts b/tootallnate-once/src/overloaded-parameters.ts new file mode 100644 index 0000000..d87fbb4 --- /dev/null +++ b/tootallnate-once/src/overloaded-parameters.ts @@ -0,0 +1,22 @@ +export type OverloadedParameters = + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; (...args: infer A8): any; (...args: infer A9): any; (...args: infer A10): any; (...args: infer A11): any; (...args: infer A12): any; (...args: infer A13): any; (...args: infer A14): any; (...args: infer A15): any; (...args: infer A16): any; (...args: infer A17): any; (...args: infer A18): any; (...args: infer A19): any; (...args: infer A20): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 | A9 | A10 | A11 | A12 | A13 | A14 | A15 | A16 | A17 | A18 | A19 | A20 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; (...args: infer A8): any; (...args: infer A9): any; (...args: infer A10): any; (...args: infer A11): any; (...args: infer A12): any; (...args: infer A13): any; (...args: infer A14): any; (...args: infer A15): any; (...args: infer A16): any; (...args: infer A17): any; (...args: infer A18): any; (...args: infer A19): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 | A9 | A10 | A11 | A12 | A13 | A14 | A15 | A16 | A17 | A18 | A19 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; (...args: infer A8): any; (...args: infer A9): any; (...args: infer A10): any; (...args: infer A11): any; (...args: infer A12): any; (...args: infer A13): any; (...args: infer A14): any; (...args: infer A15): any; (...args: infer A16): any; (...args: infer A17): any; (...args: infer A18): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 | A9 | A10 | A11 | A12 | A13 | A14 | A15 | A16 | A17 | A18 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; (...args: infer A8): any; (...args: infer A9): any; (...args: infer A10): any; (...args: infer A11): any; (...args: infer A12): any; (...args: infer A13): any; (...args: infer A14): any; (...args: infer A15): any; (...args: infer A16): any; (...args: infer A17): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 | A9 | A10 | A11 | A12 | A13 | A14 | A15 | A16 | A17 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; (...args: infer A8): any; (...args: infer A9): any; (...args: infer A10): any; (...args: infer A11): any; (...args: infer A12): any; (...args: infer A13): any; (...args: infer A14): any; (...args: infer A15): any; (...args: infer A16): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 | A9 | A10 | A11 | A12 | A13 | A14 | A15 | A16 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; (...args: infer A8): any; (...args: infer A9): any; (...args: infer A10): any; (...args: infer A11): any; (...args: infer A12): any; (...args: infer A13): any; (...args: infer A14): any; (...args: infer A15): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 | A9 | A10 | A11 | A12 | A13 | A14 | A15 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; (...args: infer A8): any; (...args: infer A9): any; (...args: infer A10): any; (...args: infer A11): any; (...args: infer A12): any; (...args: infer A13): any; (...args: infer A14): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 | A9 | A10 | A11 | A12 | A13 | A14 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; (...args: infer A8): any; (...args: infer A9): any; (...args: infer A10): any; (...args: infer A11): any; (...args: infer A12): any; (...args: infer A13): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 | A9 | A10 | A11 | A12 | A13 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; (...args: infer A8): any; (...args: infer A9): any; (...args: infer A10): any; (...args: infer A11): any; (...args: infer A12): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 | A9 | A10 | A11 | A12 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; (...args: infer A8): any; (...args: infer A9): any; (...args: infer A10): any; (...args: infer A11): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 | A9 | A10 | A11 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; (...args: infer A8): any; (...args: infer A9): any; (...args: infer A10): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 | A9 | A10 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; (...args: infer A8): any; (...args: infer A9): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 | A9 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; (...args: infer A8): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 | A8 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; (...args: infer A7): any; } ? A1 | A2 | A3 | A4 | A5 | A6 | A7 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; (...args: infer A6): any; } ? A1 | A2 | A3 | A4 | A5 | A6 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; (...args: infer A5): any; } ? A1 | A2 | A3 | A4 | A5 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; (...args: infer A4): any; } ? A1 | A2 | A3 | A4 : + T extends { (...args: infer A1): any; (...args: infer A2): any; (...args: infer A3): any; } ? A1 | A2 | A3 : + T extends { (...args: infer A1): any; (...args: infer A2): any; } ? A1 | A2 : + T extends { (...args: infer A1): any; } ? A1 : + any; diff --git a/tootallnate-once/src/types.ts b/tootallnate-once/src/types.ts new file mode 100644 index 0000000..d8ede72 --- /dev/null +++ b/tootallnate-once/src/types.ts @@ -0,0 +1,38 @@ +import { EventEmitter } from 'events'; +import { OverloadedParameters } from './overloaded-parameters'; + +export type FirstParameter = T extends [infer R, ...any[]] ? R : never; + +export type EventListener = F extends [ + T, + infer R, + ...any[] +] + ? R + : never; + +export type EventParameters< + Emitter extends EventEmitter +> = OverloadedParameters; + +export type EventNames = FirstParameter< + EventParameters +>; + +export type EventListenerParameters< + Emitter extends EventEmitter, + Event extends EventNames +> = WithDefault< + Parameters, Event>>, + unknown[] +>; + +export type WithDefault = [T] extends [never] ? D : T; + +export interface AbortSignal { + addEventListener: (name: string, listener: (...args: any[]) => any) => void; + removeEventListener: ( + name: string, + listener: (...args: any[]) => any + ) => void; +} diff --git a/tootallnate-once/test/once.test.ts b/tootallnate-once/test/once.test.ts new file mode 100644 index 0000000..d2169bd --- /dev/null +++ b/tootallnate-once/test/once.test.ts @@ -0,0 +1,117 @@ +import { spawn } from 'child_process'; +import { EventEmitter } from 'events'; +import { AbortController } from 'abort-controller'; +import once from '../src'; + +describe('once()', () => { + it('should work with vanilla EventEmitter', async () => { + const emitter = new EventEmitter(); + const promise = once(emitter, 'foo'); + emitter.emit('foo', 'bar'); + const [foo] = await promise; + expect(foo).toEqual('bar'); + }); + + it('should work with vanilla EventEmitter - nextTick', async () => { + const ee = new EventEmitter(); + process.nextTick(() => { + ee.emit('myevent', 42); + }); + const [value] = await once(ee, 'myevent'); + expect(value).toEqual(42); + }); + + it('should reject Promise upon "error" event', async () => { + let err: Error | null = null; + const emitter = new EventEmitter(); + const promise = once(emitter, 'foo').catch((_err) => { + err = _err; + }); + emitter.emit('error', new Error('test')); + await promise; + expect(err!.message).toEqual('test'); + }); + + it('should reject Promise upon "error" event - nextTick', async () => { + const ee = new EventEmitter(); + + const err = new Error('kaboom'); + process.nextTick(() => { + ee.emit('error', err); + }); + + try { + await once(ee, 'myevent'); + throw new Error('Should not happen'); + } catch (err: any) { + expect(err.message).toEqual('kaboom'); + } + }); + + it('should work with interface extending EventEmitter with overload', async () => { + interface TestEmitter extends EventEmitter { + on(name: 'foo', listener: (a: string, b: number) => void): this; + } + + const emitter: TestEmitter = new EventEmitter(); + const promise = once(emitter, 'foo'); + emitter.emit('foo', 'bar', 4); + const [a, b] = await promise; + expect(a).toEqual('bar'); + expect(b).toEqual(4); + }); + + it('should allow casting from an `any` param', async () => { + interface TestEmitter extends EventEmitter { + on(name: 'foo', listener: (a: any, b: number) => void): this; + } + + const emitter: TestEmitter = new EventEmitter(); + const promise: Promise<[string, unknown]> = once(emitter, 'foo'); + emitter.emit('foo', 'bar', 4); + const [a] = await promise; + // TypeScript will fail if `a` is not `string` type + expect(a.toUpperCase()).toEqual('BAR'); + }); + + it('should work with ChildProcess "exit" event', async () => { + const child = spawn('echo', ['hi']); + const [code, signal] = await once(child, 'exit'); + expect(code).toEqual(0); + expect(signal).toBeNull(); + }); + + it('should be abortable with `AbortController`', async () => { + let wasResolved = false; + const emitter = new EventEmitter(); + const controller = new AbortController(); + const { signal } = controller; + + const onResolve = () => { + wasResolved = true; + }; + once(emitter, 'foo', { signal }).then(onResolve, onResolve); + + // First time without `abort()`, so it will be fulfilled + emitter.emit('foo'); + + // Promise is fulfilled on next tick, so wait a bit + await new Promise((r) => process.nextTick(r)); + + expect(wasResolved).toEqual(true); + + // Reset + wasResolved = false; + + once(emitter, 'foo', { signal }).then(onResolve, onResolve); + + // This time abort + controller.abort(); + emitter.emit('foo'); + + // Promise is fulfilled on next tick, so wait a bit + await new Promise((r) => process.nextTick(r)); + + expect(wasResolved).toEqual(false); + }); +}); diff --git a/tootallnate-once/test/tsconfig.json b/tootallnate-once/test/tsconfig.json new file mode 100644 index 0000000..3e4eb46 --- /dev/null +++ b/tootallnate-once/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.test.ts"] +} diff --git a/tootallnate-once/tsconfig.json b/tootallnate-once/tsconfig.json new file mode 100644 index 0000000..d108a55 --- /dev/null +++ b/tootallnate-once/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "module": "ES2020", + "target": "ES2019", + "esModuleInterop": true, + "lib": ["ES2019"], + "outDir": "dist", + "sourceMap": true, + "declaration": true, + "skipLibCheck": true, + "moduleResolution": "node", + "typeRoots": [ + "./@types", + "./node_modules/@types" + ] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..63692a7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "module": "CommonJS", + "target": "es2015", + "esModuleInterop": true, + "lib": ["esnext"], + "outDir": "dist", + "sourceMap": true, + "declaration": true, + "typeRoots": [ + "./@types", + "./node_modules/@types" + ] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +}