From 91b7bf978c1a6d4af4f2cf6d9dc76db045df94c2 Mon Sep 17 00:00:00 2001
From: Brian Cristante <33549821+brcrista@users.noreply.github.com>
Date: Tue, 3 May 2022 11:10:13 -0400
Subject: [PATCH] Move @actions/http-client into the toolkit (#1062)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
💡 See https://github.com/actions/toolkit/pull/1064 for a better diff!
https://github.com/actions/toolkit contains a variety of packages used for building actions. https://github.com/actions/http-client is one such package, but lives outside of the toolkit. Moving it inside of the toolkit will improve discoverability and reduce the number of repos we have to keep track of for maintenance tasks (such as github/c2c-actions-service#2937).
I checked with @bryanmacfarlane on the historical decision here. Apparently it was just inertia from before we released the toolkit as multiple packages.
The benefits here are:
- Have one fewer repo to keep track of
- Signal that this is an HTTP client meant for building actions, not for general use.
## Notes
- `@actions/http-client` will continue to be released as its own package.
- Bumping the package version to **2.0.0**. Since we're compiling in strict mode now, there are some breaking changes to the exported types. This is an improvement because the null-unsafe version of`http-client` is currently breaking the safety of null-safe consumers.
- I'm not updating the other packages to use the new version in this PR. I plan to do that in a follow-up. We'll hold off on publishing `http-client` v2 to NPM until that's done just in case other changes shake out of it.
---
.github/workflows/releases.yml | 30 +-
README.md | 9 +
packages/http-client/.gitignore | 2 +
packages/http-client/LICENSE | 21 +
packages/http-client/README.md | 73 ++
packages/http-client/RELEASES.md | 36 +
packages/http-client/__tests__/auth.test.ts | 73 ++
packages/http-client/__tests__/basics.test.ts | 374 +++++++++
.../http-client/__tests__/headers.test.ts | 116 +++
.../http-client/__tests__/keepalive.test.ts | 73 ++
packages/http-client/__tests__/proxy.test.ts | 232 ++++++
packages/http-client/package-lock.json | 332 ++++++++
packages/http-client/package.json | 46 ++
packages/http-client/src/auth.ts | 86 ++
packages/http-client/src/index.ts | 773 ++++++++++++++++++
packages/http-client/src/interfaces.ts | 91 +++
packages/http-client/src/proxy.ts | 60 ++
packages/http-client/tsconfig.json | 11 +
scripts/create-package | 4 +-
19 files changed, 2425 insertions(+), 17 deletions(-)
create mode 100644 packages/http-client/.gitignore
create mode 100644 packages/http-client/LICENSE
create mode 100644 packages/http-client/README.md
create mode 100644 packages/http-client/RELEASES.md
create mode 100644 packages/http-client/__tests__/auth.test.ts
create mode 100644 packages/http-client/__tests__/basics.test.ts
create mode 100644 packages/http-client/__tests__/headers.test.ts
create mode 100644 packages/http-client/__tests__/keepalive.test.ts
create mode 100644 packages/http-client/__tests__/proxy.test.ts
create mode 100644 packages/http-client/package-lock.json
create mode 100644 packages/http-client/package.json
create mode 100644 packages/http-client/src/auth.ts
create mode 100644 packages/http-client/src/index.ts
create mode 100644 packages/http-client/src/interfaces.ts
create mode 100644 packages/http-client/src/proxy.ts
create mode 100644 packages/http-client/tsconfig.json
diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml
index b5f21974..cbabd034 100644
--- a/.github/workflows/releases.yml
+++ b/.github/workflows/releases.yml
@@ -5,8 +5,8 @@ on:
inputs:
package:
required: true
- description: 'core, artifact, cache, exec, github, glob, io, tool-cache'
-
+ description: 'core, artifact, cache, exec, github, glob, http-client, io, tool-cache'
+
jobs:
test:
runs-on: macos-latest
@@ -17,40 +17,40 @@ jobs:
- name: verify package exists
run: ls packages/${{ github.event.inputs.package }}
-
+
- name: Set Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.x
-
+
- name: npm install
run: npm install
-
+
- name: bootstrap
run: npm run bootstrap
-
+
- name: build
run: npm run build
-
+
- name: test
run: npm run test
- name: pack
run: npm pack
working-directory: packages/${{ github.event.inputs.package }}
-
+
- name: upload artifact
uses: actions/upload-artifact@v2
with:
name: ${{ github.event.inputs.package }}
path: packages/${{ github.event.inputs.package }}/*.tgz
-
+
publish:
runs-on: macos-latest
needs: test
environment: npm-publish
steps:
-
+
- name: download artifact
uses: actions/download-artifact@v2
with:
@@ -58,7 +58,7 @@ jobs:
- name: setup authentication
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
- env:
+ env:
NPM_TOKEN: ${{ secrets.TOKEN }}
- name: publish
@@ -68,13 +68,13 @@ jobs:
if: failure()
run: |
curl -X POST -H 'Content-type: application/json' --data '{"text":":pb__failed: Failed to publish a new version of ${{ github.event.inputs.package }}"}' $SLACK_WEBHOOK
- env:
+ env:
SLACK_WEBHOOK: ${{ secrets.SLACK }}
-
+
- name: notify slack on success
if: success()
run: |
curl -X POST -H 'Content-type: application/json' --data '{"text":":dance: Successfully published a new version of ${{ github.event.inputs.package }}"}' $SLACK_WEBHOOK
- env:
+ env:
SLACK_WEBHOOK: ${{ secrets.SLACK }}
-
+
diff --git a/README.md b/README.md
index 7571d1eb..43ee8acd 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,15 @@ $ npm install @actions/glob
```
+:phone: [@actions/http-client](packages/http-client)
+
+A lightweight HTTP client optimized for building actions. Read more [here](packages/http-client)
+
+```bash
+$ npm install @actions/http-client
+```
+
+
:pencil2: [@actions/io](packages/io)
Provides disk i/o functions like cp, mv, rmRF, which etc. Read more [here](packages/io)
diff --git a/packages/http-client/.gitignore b/packages/http-client/.gitignore
new file mode 100644
index 00000000..d481b576
--- /dev/null
+++ b/packages/http-client/.gitignore
@@ -0,0 +1,2 @@
+testoutput.txt
+npm-debug.log
diff --git a/packages/http-client/LICENSE b/packages/http-client/LICENSE
new file mode 100644
index 00000000..5823a51c
--- /dev/null
+++ b/packages/http-client/LICENSE
@@ -0,0 +1,21 @@
+Actions Http Client for Node.js
+
+Copyright (c) GitHub, Inc.
+
+All rights reserved.
+
+MIT License
+
+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/packages/http-client/README.md b/packages/http-client/README.md
new file mode 100644
index 00000000..7e06adeb
--- /dev/null
+++ b/packages/http-client/README.md
@@ -0,0 +1,73 @@
+# `@actions/http-client`
+
+A lightweight HTTP client optimized for building actions.
+
+## Features
+
+ - HTTP client with TypeScript generics and async/await/Promises
+ - Typings included!
+ - [Proxy support](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/about-self-hosted-runners#using-a-proxy-server-with-self-hosted-runners) just works with actions and the runner
+ - Targets ES2019 (runner runs actions with node 12+). Only supported on node 12+.
+ - Basic, Bearer and PAT Support out of the box. Extensible handlers for others.
+ - Redirects supported
+
+Features and releases [here](./RELEASES.md)
+
+## Install
+
+```
+npm install @actions/http-client --save
+```
+
+## Samples
+
+See the [tests](./__tests__) for detailed examples.
+
+## Errors
+
+### HTTP
+
+The HTTP client does not throw unless truly exceptional.
+
+* A request that successfully executes resulting in a 404, 500 etc... will return a response object with a status code and a body.
+* Redirects (3xx) will be followed by default.
+
+See the [tests](./__tests__) for detailed examples.
+
+## Debugging
+
+To enable detailed console logging of all HTTP requests and responses, set the NODE_DEBUG environment varible:
+
+```shell
+export NODE_DEBUG=http
+```
+
+## Node support
+
+The http-client is built using the latest LTS version of Node 12. It may work on previous node LTS versions but it's tested and officially supported on Node12+.
+
+## Support and Versioning
+
+We follow semver and will hold compatibility between major versions and increment the minor version with new features and capabilities (while holding compat).
+
+## Contributing
+
+We welcome PRs. Please create an issue and if applicable, a design before proceeding with code.
+
+once:
+
+```
+npm install
+```
+
+To build:
+
+```
+npm run build
+```
+
+To run all tests:
+
+```
+npm test
+```
diff --git a/packages/http-client/RELEASES.md b/packages/http-client/RELEASES.md
new file mode 100644
index 00000000..a097a4e0
--- /dev/null
+++ b/packages/http-client/RELEASES.md
@@ -0,0 +1,36 @@
+## Releases
+
+## 2.0.0
+- The package is now compiled with TypeScript's [`strict` compiler setting](https://www.typescriptlang.org/tsconfig#strict). To comply with stricter rules:
+ - Some exported types now include `| null` or `| undefined`, matching their actual behavior.
+ - Types implementing the method `RequestHandler.handleAuthentication()` now throw an `Error` rather than returning `null` if they do not support handling an HTTP 401 response. Callers can still use `canHandleAuthentication()` to determine if this handling is supported or not.
+ - Types using `any` have been scoped to more specific types.
+- Following TypeScript's naming conventions, exported interfaces no longer begin with the prefix `I-`.
+- Delete the `IHttpClientResponse` interface in favor of the `HttpClientResponse` class.
+- Delete the `IHeaders` interface in favor of `http.OutgoingHttpHeaders`.
+- The source code of the package was moved to build with [actions/toolkit](https://github.com/actions/toolkit).
+
+## 1.0.11
+
+Contains a bug fix where proxy is defined without a user and password. see [PR here](https://github.com/actions/http-client/pull/42)
+
+## 1.0.9
+Throw HttpClientError instead of a generic Error from the \Json() helper methods when the server responds with a non-successful status code.
+
+## 1.0.8
+Fixed security issue where a redirect (e.g. 302) to another domain would pass headers. The fix was to strip the authorization header if the hostname was different. More [details in PR #27](https://github.com/actions/http-client/pull/27)
+
+## 1.0.7
+Update NPM dependencies and add 429 to the list of HttpCodes
+
+## 1.0.6
+Automatically sends Content-Type and Accept application/json headers for \Json() helper methods if not set in the client or parameters.
+
+## 1.0.5
+Adds \Json() helper methods for json over http scenarios.
+
+## 1.0.4
+Started to add \Json() helper methods. Do not use this release for that. Use >= 1.0.5 since there was an issue with types.
+
+## 1.0.1 to 1.0.3
+Adds proxy support.
diff --git a/packages/http-client/__tests__/auth.test.ts b/packages/http-client/__tests__/auth.test.ts
new file mode 100644
index 00000000..878fafe9
--- /dev/null
+++ b/packages/http-client/__tests__/auth.test.ts
@@ -0,0 +1,73 @@
+import * as httpm from '../lib'
+import * as am from '../lib/auth'
+
+describe('auth', () => {
+ beforeEach(() => {})
+
+ afterEach(() => {})
+
+ it('does basic http get request with basic auth', async () => {
+ const bh: am.BasicCredentialHandler = new am.BasicCredentialHandler(
+ 'johndoe',
+ 'password'
+ )
+ const http: httpm.HttpClient = new httpm.HttpClient('http-client-tests', [
+ bh
+ ])
+ const res: httpm.HttpClientResponse = await http.get(
+ 'http://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ const auth: string = obj.headers.Authorization
+ const creds: string = Buffer.from(
+ auth.substring('Basic '.length),
+ 'base64'
+ ).toString()
+ expect(creds).toBe('johndoe:password')
+ expect(obj.url).toBe('http://httpbin.org/get')
+ })
+
+ it('does basic http get request with pat token auth', async () => {
+ const token = 'scbfb44vxzku5l4xgc3qfazn3lpk4awflfryc76esaiq7aypcbhs'
+ const ph: am.PersonalAccessTokenCredentialHandler = new am.PersonalAccessTokenCredentialHandler(
+ token
+ )
+
+ const http: httpm.HttpClient = new httpm.HttpClient('http-client-tests', [
+ ph
+ ])
+ const res: httpm.HttpClientResponse = await http.get(
+ 'http://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ const auth: string = obj.headers.Authorization
+ const creds: string = Buffer.from(
+ auth.substring('Basic '.length),
+ 'base64'
+ ).toString()
+ expect(creds).toBe(`PAT:${token}`)
+ expect(obj.url).toBe('http://httpbin.org/get')
+ })
+
+ it('does basic http get request with pat token auth', async () => {
+ const token = 'scbfb44vxzku5l4xgc3qfazn3lpk4awflfryc76esaiq7aypcbhs'
+ const ph: am.BearerCredentialHandler = new am.BearerCredentialHandler(token)
+
+ const http: httpm.HttpClient = new httpm.HttpClient('http-client-tests', [
+ ph
+ ])
+ const res: httpm.HttpClientResponse = await http.get(
+ 'http://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ const auth: string = obj.headers.Authorization
+ expect(auth).toBe(`Bearer ${token}`)
+ expect(obj.url).toBe('http://httpbin.org/get')
+ })
+})
diff --git a/packages/http-client/__tests__/basics.test.ts b/packages/http-client/__tests__/basics.test.ts
new file mode 100644
index 00000000..7732264a
--- /dev/null
+++ b/packages/http-client/__tests__/basics.test.ts
@@ -0,0 +1,374 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import * as httpm from '..'
+import * as path from 'path'
+import * as fs from 'fs'
+
+const sampleFilePath: string = path.join(__dirname, 'testoutput.txt')
+
+interface HttpBinData {
+ url: string
+ data: any
+ json: any
+ headers: any
+ args?: any
+}
+
+describe('basics', () => {
+ let _http: httpm.HttpClient
+
+ beforeEach(() => {
+ _http = new httpm.HttpClient('http-client-tests')
+ })
+
+ afterEach(() => {})
+
+ it('constructs', () => {
+ const http: httpm.HttpClient = new httpm.HttpClient('thttp-client-tests')
+ expect(http).toBeDefined()
+ })
+
+ // responses from httpbin return something like:
+ // {
+ // "args": {},
+ // "headers": {
+ // "Connection": "close",
+ // "Host": "httpbin.org",
+ // "User-Agent": "typed-test-client-tests"
+ // },
+ // "origin": "173.95.152.44",
+ // "url": "https://httpbin.org/get"
+ // }
+
+ it('does basic http get request', async () => {
+ const res: httpm.HttpClientResponse = await _http.get(
+ 'http://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.url).toBe('http://httpbin.org/get')
+ expect(obj.headers['User-Agent']).toBeTruthy()
+ })
+
+ it('does basic http get request with no user agent', async () => {
+ const http: httpm.HttpClient = new httpm.HttpClient()
+ const res: httpm.HttpClientResponse = await http.get(
+ 'http://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.url).toBe('http://httpbin.org/get')
+ expect(obj.headers['User-Agent']).toBeFalsy()
+ })
+
+ it('does basic https get request', async () => {
+ const res: httpm.HttpClientResponse = await _http.get(
+ 'https://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.url).toBe('https://httpbin.org/get')
+ })
+
+ it('does basic http get request with default headers', async () => {
+ const http: httpm.HttpClient = new httpm.HttpClient(
+ 'http-client-tests',
+ [],
+ {
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json'
+ }
+ }
+ )
+ const res: httpm.HttpClientResponse = await http.get(
+ 'http://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.headers.Accept).toBe('application/json')
+ expect(obj.headers['Content-Type']).toBe('application/json')
+ expect(obj.url).toBe('http://httpbin.org/get')
+ })
+
+ it('does basic http get request with merged headers', async () => {
+ const http: httpm.HttpClient = new httpm.HttpClient(
+ 'http-client-tests',
+ [],
+ {
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json'
+ }
+ }
+ )
+ const res: httpm.HttpClientResponse = await http.get(
+ 'http://httpbin.org/get',
+ {
+ 'content-type': 'application/x-www-form-urlencoded'
+ }
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.headers.Accept).toBe('application/json')
+ expect(obj.headers['Content-Type']).toBe(
+ 'application/x-www-form-urlencoded'
+ )
+ expect(obj.url).toBe('http://httpbin.org/get')
+ })
+
+ it('pipes a get request', async () => {
+ return new Promise(async resolve => {
+ const file = fs.createWriteStream(sampleFilePath)
+ ;(await _http.get('https://httpbin.org/get')).message
+ .pipe(file)
+ .on('close', () => {
+ const body: string = fs.readFileSync(sampleFilePath).toString()
+ const obj = JSON.parse(body)
+ expect(obj.url).toBe('https://httpbin.org/get')
+ resolve()
+ })
+ })
+ })
+
+ it('does basic get request with redirects', async () => {
+ const res: httpm.HttpClientResponse = await _http.get(
+ `https://httpbin.org/redirect-to?url=${encodeURIComponent(
+ 'https://httpbin.org/get'
+ )}`
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.url).toBe('https://httpbin.org/get')
+ })
+
+ it('does basic get request with redirects (303)', async () => {
+ const res: httpm.HttpClientResponse = await _http.get(
+ `https://httpbin.org/redirect-to?url=${encodeURIComponent(
+ 'https://httpbin.org/get'
+ )}&status_code=303`
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.url).toBe('https://httpbin.org/get')
+ })
+
+ it('returns 404 for not found get request on redirect', async () => {
+ const res: httpm.HttpClientResponse = await _http.get(
+ `https://httpbin.org/redirect-to?url=${encodeURIComponent(
+ 'https://httpbin.org/status/404'
+ )}&status_code=303`
+ )
+ expect(res.message.statusCode).toBe(404)
+ await res.readBody()
+ })
+
+ it('does not follow redirects if disabled', async () => {
+ const http: httpm.HttpClient = new httpm.HttpClient(
+ 'typed-test-client-tests',
+ undefined,
+ {allowRedirects: false}
+ )
+ const res: httpm.HttpClientResponse = await http.get(
+ `https://httpbin.org/redirect-to?url=${encodeURIComponent(
+ 'https://httpbin.org/get'
+ )}`
+ )
+ expect(res.message.statusCode).toBe(302)
+ await res.readBody()
+ })
+
+ it('does not pass auth with diff hostname redirects', async () => {
+ const headers = {
+ accept: 'application/json',
+ authorization: 'shhh'
+ }
+ const res: httpm.HttpClientResponse = await _http.get(
+ `https://httpbin.org/redirect-to?url=${encodeURIComponent(
+ 'https://www.httpbin.org/get'
+ )}`,
+ headers
+ )
+
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ // httpbin "fixes" the casing
+ expect(obj.headers['Accept']).toBe('application/json')
+ expect(obj.headers['Authorization']).toBeUndefined()
+ expect(obj.headers['authorization']).toBeUndefined()
+ expect(obj.url).toBe('https://www.httpbin.org/get')
+ })
+
+ it('does not pass Auth with diff hostname redirects', async () => {
+ const headers = {
+ Accept: 'application/json',
+ Authorization: 'shhh'
+ }
+ const res: httpm.HttpClientResponse = await _http.get(
+ `https://httpbin.org/redirect-to?url=${encodeURIComponent(
+ 'https://www.httpbin.org/get'
+ )}`,
+ headers
+ )
+
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ // httpbin "fixes" the casing
+ expect(obj.headers['Accept']).toBe('application/json')
+ expect(obj.headers['Authorization']).toBeUndefined()
+ expect(obj.headers['authorization']).toBeUndefined()
+ expect(obj.url).toBe('https://www.httpbin.org/get')
+ })
+
+ it('does basic head request', async () => {
+ const res: httpm.HttpClientResponse = await _http.head(
+ 'http://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ })
+
+ it('does basic http delete request', async () => {
+ const res: httpm.HttpClientResponse = await _http.del(
+ 'http://httpbin.org/delete'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ JSON.parse(body)
+ })
+
+ it('does basic http post request', async () => {
+ const b = 'Hello World!'
+ const res: httpm.HttpClientResponse = await _http.post(
+ 'http://httpbin.org/post',
+ b
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.data).toBe(b)
+ expect(obj.url).toBe('http://httpbin.org/post')
+ })
+
+ it('does basic http patch request', async () => {
+ const b = 'Hello World!'
+ const res: httpm.HttpClientResponse = await _http.patch(
+ 'http://httpbin.org/patch',
+ b
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.data).toBe(b)
+ expect(obj.url).toBe('http://httpbin.org/patch')
+ })
+
+ it('does basic http options request', async () => {
+ const res: httpm.HttpClientResponse = await _http.options(
+ 'http://httpbin.org'
+ )
+ expect(res.message.statusCode).toBe(200)
+ await res.readBody()
+ })
+
+ it('returns 404 for not found get request', async () => {
+ const res: httpm.HttpClientResponse = await _http.get(
+ 'http://httpbin.org/status/404'
+ )
+ expect(res.message.statusCode).toBe(404)
+ await res.readBody()
+ })
+
+ it('gets a json object', async () => {
+ const jsonObj = await _http.getJson('https://httpbin.org/get')
+ expect(jsonObj.statusCode).toBe(200)
+ expect(jsonObj.result).toBeDefined()
+ expect(jsonObj.result?.url).toBe('https://httpbin.org/get')
+ expect(jsonObj.result?.headers['Accept']).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ expect(jsonObj.headers[httpm.Headers.ContentType]).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ })
+
+ it('getting a non existent json object returns null', async () => {
+ const jsonObj = await _http.getJson(
+ 'https://httpbin.org/status/404'
+ )
+ expect(jsonObj.statusCode).toBe(404)
+ expect(jsonObj.result).toBeNull()
+ })
+
+ it('posts a json object', async () => {
+ const res = {name: 'foo'}
+ const restRes = await _http.postJson(
+ 'https://httpbin.org/post',
+ res
+ )
+ expect(restRes.statusCode).toBe(200)
+ expect(restRes.result).toBeDefined()
+ expect(restRes.result?.url).toBe('https://httpbin.org/post')
+ expect(restRes.result?.json.name).toBe('foo')
+ expect(restRes.result?.headers['Accept']).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ expect(restRes.result?.headers['Content-Type']).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ expect(restRes.headers[httpm.Headers.ContentType]).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ })
+
+ it('puts a json object', async () => {
+ const res = {name: 'foo'}
+ const restRes = await _http.putJson(
+ 'https://httpbin.org/put',
+ res
+ )
+ expect(restRes.statusCode).toBe(200)
+ expect(restRes.result).toBeDefined()
+ expect(restRes.result?.url).toBe('https://httpbin.org/put')
+ expect(restRes.result?.json.name).toBe('foo')
+
+ expect(restRes.result?.headers['Accept']).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ expect(restRes.result?.headers['Content-Type']).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ expect(restRes.headers[httpm.Headers.ContentType]).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ })
+
+ it('patch a json object', async () => {
+ const res = {name: 'foo'}
+ const restRes = await _http.patchJson(
+ 'https://httpbin.org/patch',
+ res
+ )
+ expect(restRes.statusCode).toBe(200)
+ expect(restRes.result).toBeDefined()
+ expect(restRes.result?.url).toBe('https://httpbin.org/patch')
+ expect(restRes.result?.json.name).toBe('foo')
+ expect(restRes.result?.headers['Accept']).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ expect(restRes.result?.headers['Content-Type']).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ expect(restRes.headers[httpm.Headers.ContentType]).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ })
+})
diff --git a/packages/http-client/__tests__/headers.test.ts b/packages/http-client/__tests__/headers.test.ts
new file mode 100644
index 00000000..0af9563c
--- /dev/null
+++ b/packages/http-client/__tests__/headers.test.ts
@@ -0,0 +1,116 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import * as httpm from '..'
+
+describe('headers', () => {
+ let _http: httpm.HttpClient
+
+ beforeEach(() => {
+ _http = new httpm.HttpClient('http-client-tests')
+ })
+
+ it('preserves existing headers on getJson', async () => {
+ const additionalHeaders = {[httpm.Headers.Accept]: 'foo'}
+ let jsonObj = await _http.getJson(
+ 'https://httpbin.org/get',
+ additionalHeaders
+ )
+ expect(jsonObj.result.headers['Accept']).toBe('foo')
+ expect(jsonObj.headers[httpm.Headers.ContentType]).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+
+ const httpWithHeaders = new httpm.HttpClient()
+ httpWithHeaders.requestOptions = {
+ headers: {
+ [httpm.Headers.Accept]: 'baz'
+ }
+ }
+ jsonObj = await httpWithHeaders.getJson('https://httpbin.org/get')
+ expect(jsonObj.result.headers['Accept']).toBe('baz')
+ expect(jsonObj.headers[httpm.Headers.ContentType]).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ })
+
+ it('preserves existing headers on postJson', async () => {
+ const additionalHeaders = {[httpm.Headers.Accept]: 'foo'}
+ let jsonObj = await _http.postJson(
+ 'https://httpbin.org/post',
+ {},
+ additionalHeaders
+ )
+ expect(jsonObj.result.headers['Accept']).toBe('foo')
+ expect(jsonObj.headers[httpm.Headers.ContentType]).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+
+ const httpWithHeaders = new httpm.HttpClient()
+ httpWithHeaders.requestOptions = {
+ headers: {
+ [httpm.Headers.Accept]: 'baz'
+ }
+ }
+ jsonObj = await httpWithHeaders.postJson(
+ 'https://httpbin.org/post',
+ {}
+ )
+ expect(jsonObj.result.headers['Accept']).toBe('baz')
+ expect(jsonObj.headers[httpm.Headers.ContentType]).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ })
+
+ it('preserves existing headers on putJson', async () => {
+ const additionalHeaders = {[httpm.Headers.Accept]: 'foo'}
+ let jsonObj = await _http.putJson(
+ 'https://httpbin.org/put',
+ {},
+ additionalHeaders
+ )
+ expect(jsonObj.result.headers['Accept']).toBe('foo')
+ expect(jsonObj.headers[httpm.Headers.ContentType]).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+
+ const httpWithHeaders = new httpm.HttpClient()
+ httpWithHeaders.requestOptions = {
+ headers: {
+ [httpm.Headers.Accept]: 'baz'
+ }
+ }
+ jsonObj = await httpWithHeaders.putJson('https://httpbin.org/put', {})
+ expect(jsonObj.result.headers['Accept']).toBe('baz')
+ expect(jsonObj.headers[httpm.Headers.ContentType]).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ })
+
+ it('preserves existing headers on patchJson', async () => {
+ const additionalHeaders = {[httpm.Headers.Accept]: 'foo'}
+ let jsonObj = await _http.patchJson(
+ 'https://httpbin.org/patch',
+ {},
+ additionalHeaders
+ )
+ expect(jsonObj.result.headers['Accept']).toBe('foo')
+ expect(jsonObj.headers[httpm.Headers.ContentType]).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+
+ const httpWithHeaders = new httpm.HttpClient()
+ httpWithHeaders.requestOptions = {
+ headers: {
+ [httpm.Headers.Accept]: 'baz'
+ }
+ }
+ jsonObj = await httpWithHeaders.patchJson(
+ 'https://httpbin.org/patch',
+ {}
+ )
+ expect(jsonObj.result.headers['Accept']).toBe('baz')
+ expect(jsonObj.headers[httpm.Headers.ContentType]).toBe(
+ httpm.MediaTypes.ApplicationJson
+ )
+ })
+})
diff --git a/packages/http-client/__tests__/keepalive.test.ts b/packages/http-client/__tests__/keepalive.test.ts
new file mode 100644
index 00000000..ed55be20
--- /dev/null
+++ b/packages/http-client/__tests__/keepalive.test.ts
@@ -0,0 +1,73 @@
+import * as httpm from '../lib'
+
+describe('basics', () => {
+ let _http: httpm.HttpClient
+
+ beforeEach(() => {
+ _http = new httpm.HttpClient('http-client-tests', [], {keepAlive: true})
+ })
+
+ afterEach(() => {
+ _http.dispose()
+ })
+
+ it('does basic http get request with keepAlive true', async () => {
+ const res: httpm.HttpClientResponse = await _http.get(
+ 'http://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.url).toBe('http://httpbin.org/get')
+ })
+
+ it('does basic head request with keepAlive true', async () => {
+ const res: httpm.HttpClientResponse = await _http.head(
+ 'http://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ })
+
+ it('does basic http delete request with keepAlive true', async () => {
+ const res: httpm.HttpClientResponse = await _http.del(
+ 'http://httpbin.org/delete'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ JSON.parse(body)
+ })
+
+ it('does basic http post request with keepAlive true', async () => {
+ const b = 'Hello World!'
+ const res: httpm.HttpClientResponse = await _http.post(
+ 'http://httpbin.org/post',
+ b
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.data).toBe(b)
+ expect(obj.url).toBe('http://httpbin.org/post')
+ })
+
+ it('does basic http patch request with keepAlive true', async () => {
+ const b = 'Hello World!'
+ const res: httpm.HttpClientResponse = await _http.patch(
+ 'http://httpbin.org/patch',
+ b
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.data).toBe(b)
+ expect(obj.url).toBe('http://httpbin.org/patch')
+ })
+
+ it('does basic http options request with keepAlive true', async () => {
+ const res: httpm.HttpClientResponse = await _http.options(
+ 'http://httpbin.org'
+ )
+ expect(res.message.statusCode).toBe(200)
+ await res.readBody()
+ })
+})
diff --git a/packages/http-client/__tests__/proxy.test.ts b/packages/http-client/__tests__/proxy.test.ts
new file mode 100644
index 00000000..62e8e962
--- /dev/null
+++ b/packages/http-client/__tests__/proxy.test.ts
@@ -0,0 +1,232 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import * as http from 'http'
+import * as httpm from '../lib/'
+import * as pm from '../lib/proxy'
+// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
+const proxy = require('proxy')
+
+let _proxyConnects: string[]
+let _proxyServer: http.Server
+const _proxyUrl = 'http://127.0.0.1:8080'
+
+describe('proxy', () => {
+ beforeAll(async () => {
+ // Start proxy server
+ _proxyServer = proxy()
+ await new Promise(resolve => {
+ const port = Number(_proxyUrl.split(':')[2])
+ _proxyServer.listen(port, () => resolve())
+ })
+ _proxyServer.on('connect', req => {
+ _proxyConnects.push(req.url)
+ })
+ })
+
+ beforeEach(() => {
+ _proxyConnects = []
+ _clearVars()
+ })
+
+ afterEach(() => {})
+
+ afterAll(async () => {
+ _clearVars()
+
+ // Stop proxy server
+ await new Promise(resolve => {
+ _proxyServer.once('close', () => resolve())
+ _proxyServer.close()
+ })
+ })
+
+ it('getProxyUrl does not return proxyUrl if variables not set', () => {
+ const proxyUrl = pm.getProxyUrl(new URL('https://github.com'))
+ expect(proxyUrl).toBeUndefined()
+ })
+
+ it('getProxyUrl returns proxyUrl if https_proxy set for https url', () => {
+ process.env['https_proxy'] = 'https://myproxysvr'
+ const proxyUrl = pm.getProxyUrl(new URL('https://github.com'))
+ expect(proxyUrl).toBeDefined()
+ })
+
+ it('getProxyUrl does not return proxyUrl if http_proxy set for https url', () => {
+ process.env['http_proxy'] = 'https://myproxysvr'
+ const proxyUrl = pm.getProxyUrl(new URL('https://github.com'))
+ expect(proxyUrl).toBeUndefined()
+ })
+
+ it('getProxyUrl returns proxyUrl if http_proxy set for http url', () => {
+ process.env['http_proxy'] = 'http://myproxysvr'
+ const proxyUrl = pm.getProxyUrl(new URL('http://github.com'))
+ expect(proxyUrl).toBeDefined()
+ })
+
+ it('getProxyUrl does not return proxyUrl if https_proxy set and in no_proxy list', () => {
+ process.env['https_proxy'] = 'https://myproxysvr'
+ process.env['no_proxy'] = 'otherserver,myserver,anotherserver:8080'
+ const proxyUrl = pm.getProxyUrl(new URL('https://myserver'))
+ expect(proxyUrl).toBeUndefined()
+ })
+
+ it('getProxyUrl returns proxyUrl if https_proxy set and not in no_proxy list', () => {
+ process.env['https_proxy'] = 'https://myproxysvr'
+ process.env['no_proxy'] = 'otherserver,myserver,anotherserver:8080'
+ const proxyUrl = pm.getProxyUrl(new URL('https://github.com'))
+ expect(proxyUrl).toBeDefined()
+ })
+
+ it('getProxyUrl does not return proxyUrl if http_proxy set and in no_proxy list', () => {
+ process.env['http_proxy'] = 'http://myproxysvr'
+ process.env['no_proxy'] = 'otherserver,myserver,anotherserver:8080'
+ const proxyUrl = pm.getProxyUrl(new URL('http://myserver'))
+ expect(proxyUrl).toBeUndefined()
+ })
+
+ it('getProxyUrl returns proxyUrl if http_proxy set and not in no_proxy list', () => {
+ process.env['http_proxy'] = 'http://myproxysvr'
+ process.env['no_proxy'] = 'otherserver,myserver,anotherserver:8080'
+ const proxyUrl = pm.getProxyUrl(new URL('http://github.com'))
+ expect(proxyUrl).toBeDefined()
+ })
+
+ it('checkBypass returns true if host as no_proxy list', () => {
+ process.env['no_proxy'] = 'myserver'
+ const bypass = pm.checkBypass(new URL('https://myserver'))
+ expect(bypass).toBeTruthy()
+ })
+
+ it('checkBypass returns true if host in no_proxy list', () => {
+ process.env['no_proxy'] = 'otherserver,myserver,anotherserver:8080'
+ const bypass = pm.checkBypass(new URL('https://myserver'))
+ expect(bypass).toBeTruthy()
+ })
+
+ it('checkBypass returns true if host in no_proxy list with spaces', () => {
+ process.env['no_proxy'] = 'otherserver, myserver ,anotherserver:8080'
+ const bypass = pm.checkBypass(new URL('https://myserver'))
+ expect(bypass).toBeTruthy()
+ })
+
+ it('checkBypass returns true if host in no_proxy list with port', () => {
+ process.env['no_proxy'] = 'otherserver, myserver:8080 ,anotherserver'
+ const bypass = pm.checkBypass(new URL('https://myserver:8080'))
+ expect(bypass).toBeTruthy()
+ })
+
+ it('checkBypass returns true if host with port in no_proxy list without port', () => {
+ process.env['no_proxy'] = 'otherserver, myserver ,anotherserver'
+ const bypass = pm.checkBypass(new URL('https://myserver:8080'))
+ expect(bypass).toBeTruthy()
+ })
+
+ it('checkBypass returns true if host in no_proxy list with default https port', () => {
+ process.env['no_proxy'] = 'otherserver, myserver:443 ,anotherserver'
+ const bypass = pm.checkBypass(new URL('https://myserver'))
+ expect(bypass).toBeTruthy()
+ })
+
+ it('checkBypass returns true if host in no_proxy list with default http port', () => {
+ process.env['no_proxy'] = 'otherserver, myserver:80 ,anotherserver'
+ const bypass = pm.checkBypass(new URL('http://myserver'))
+ expect(bypass).toBeTruthy()
+ })
+
+ it('checkBypass returns false if host not in no_proxy list', () => {
+ process.env['no_proxy'] = 'otherserver, myserver ,anotherserver:8080'
+ const bypass = pm.checkBypass(new URL('https://github.com'))
+ expect(bypass).toBeFalsy()
+ })
+
+ it('checkBypass returns false if empty no_proxy', () => {
+ process.env['no_proxy'] = ''
+ const bypass = pm.checkBypass(new URL('https://github.com'))
+ expect(bypass).toBeFalsy()
+ })
+
+ it('HttpClient does basic http get request through proxy', async () => {
+ process.env['http_proxy'] = _proxyUrl
+ const httpClient = new httpm.HttpClient()
+ const res: httpm.HttpClientResponse = await httpClient.get(
+ 'http://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.url).toBe('http://httpbin.org/get')
+ expect(_proxyConnects).toEqual(['httpbin.org:80'])
+ })
+
+ it('HttoClient does basic http get request when bypass proxy', async () => {
+ process.env['http_proxy'] = _proxyUrl
+ process.env['no_proxy'] = 'httpbin.org'
+ const httpClient = new httpm.HttpClient()
+ const res: httpm.HttpClientResponse = await httpClient.get(
+ 'http://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.url).toBe('http://httpbin.org/get')
+ expect(_proxyConnects).toHaveLength(0)
+ })
+
+ it('HttpClient does basic https get request through proxy', async () => {
+ process.env['https_proxy'] = _proxyUrl
+ const httpClient = new httpm.HttpClient()
+ const res: httpm.HttpClientResponse = await httpClient.get(
+ 'https://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.url).toBe('https://httpbin.org/get')
+ expect(_proxyConnects).toEqual(['httpbin.org:443'])
+ })
+
+ it('HttpClient does basic https get request when bypass proxy', async () => {
+ process.env['https_proxy'] = _proxyUrl
+ process.env['no_proxy'] = 'httpbin.org'
+ const httpClient = new httpm.HttpClient()
+ const res: httpm.HttpClientResponse = await httpClient.get(
+ 'https://httpbin.org/get'
+ )
+ expect(res.message.statusCode).toBe(200)
+ const body: string = await res.readBody()
+ const obj = JSON.parse(body)
+ expect(obj.url).toBe('https://httpbin.org/get')
+ expect(_proxyConnects).toHaveLength(0)
+ })
+
+ it('proxyAuth not set in tunnel agent when authentication is not provided', async () => {
+ process.env['https_proxy'] = 'http://127.0.0.1:8080'
+ const httpClient = new httpm.HttpClient()
+ const agent: any = httpClient.getAgent('https://some-url')
+ // eslint-disable-next-line no-console
+ console.log(agent)
+ expect(agent.proxyOptions.host).toBe('127.0.0.1')
+ expect(agent.proxyOptions.port).toBe('8080')
+ expect(agent.proxyOptions.proxyAuth).toBe(undefined)
+ })
+
+ it('proxyAuth is set in tunnel agent when authentication is provided', async () => {
+ process.env['https_proxy'] = 'http://user:password@127.0.0.1:8080'
+ const httpClient = new httpm.HttpClient()
+ const agent: any = httpClient.getAgent('https://some-url')
+ // eslint-disable-next-line no-console
+ console.log(agent)
+ expect(agent.proxyOptions.host).toBe('127.0.0.1')
+ expect(agent.proxyOptions.port).toBe('8080')
+ expect(agent.proxyOptions.proxyAuth).toBe('user:password')
+ })
+})
+
+function _clearVars(): void {
+ delete process.env.http_proxy
+ delete process.env.HTTP_PROXY
+ delete process.env.https_proxy
+ delete process.env.HTTPS_PROXY
+ delete process.env.no_proxy
+ delete process.env.NO_PROXY
+}
diff --git a/packages/http-client/package-lock.json b/packages/http-client/package-lock.json
new file mode 100644
index 00000000..7a2f0f23
--- /dev/null
+++ b/packages/http-client/package-lock.json
@@ -0,0 +1,332 @@
+{
+ "name": "@actions/http-client",
+ "version": "2.0.0",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@actions/http-client",
+ "version": "2.0.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@types/tunnel": "0.0.3",
+ "proxy": "^1.0.1",
+ "tunnel": "0.0.6"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "12.12.31",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.31.tgz",
+ "integrity": "sha512-T+wnJno8uh27G9c+1T+a1/WYCHzLeDqtsGJkoEdSp2X8RTh3oOCZQcUnjAx90CS8cmmADX51O0FI/tu9s0yssg==",
+ "dev": true
+ },
+ "node_modules/@types/tunnel": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.3.tgz",
+ "integrity": "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/args": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/args/-/args-5.0.1.tgz",
+ "integrity": "sha512-1kqmFCFsPffavQFGt8OxJdIcETti99kySRUPMpOhaGjL6mRJn8HFU1OxKY5bMqfZKUwTQc1mZkAjmGYaVOHFtQ==",
+ "dev": true,
+ "dependencies": {
+ "camelcase": "5.0.0",
+ "chalk": "2.4.2",
+ "leven": "2.1.0",
+ "mri": "1.1.4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/basic-auth-parser": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/basic-auth-parser/-/basic-auth-parser-0.0.2.tgz",
+ "integrity": "sha1-zp5xp38jwSee7NJlmypGJEwVbkE=",
+ "dev": true
+ },
+ "node_modules/camelcase": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz",
+ "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "1.1.3"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
+ "dev": true
+ },
+ "node_modules/debug": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+ "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+ "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/leven": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz",
+ "integrity": "sha1-wuep93IJTe6dNCAq6KzORoeHVYA=",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/mri": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz",
+ "integrity": "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "node_modules/proxy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/proxy/-/proxy-1.0.2.tgz",
+ "integrity": "sha512-KNac2ueWRpjbUh77OAFPZuNdfEqNynm9DD4xHT14CccGpW8wKZwEkN0yjlb7X9G9Z9F55N0Q+1z+WfgAhwYdzQ==",
+ "dev": true,
+ "dependencies": {
+ "args": "5.0.1",
+ "basic-auth-parser": "0.0.2",
+ "debug": "^4.1.1"
+ },
+ "bin": {
+ "proxy": "bin/proxy.js"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/tunnel": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
+ "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.6.11 <=0.7.0 || >=0.7.3"
+ }
+ }
+ },
+ "dependencies": {
+ "@types/node": {
+ "version": "12.12.31",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.31.tgz",
+ "integrity": "sha512-T+wnJno8uh27G9c+1T+a1/WYCHzLeDqtsGJkoEdSp2X8RTh3oOCZQcUnjAx90CS8cmmADX51O0FI/tu9s0yssg==",
+ "dev": true
+ },
+ "@types/tunnel": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.3.tgz",
+ "integrity": "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*"
+ }
+ },
+ "ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^1.9.0"
+ }
+ },
+ "args": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/args/-/args-5.0.1.tgz",
+ "integrity": "sha512-1kqmFCFsPffavQFGt8OxJdIcETti99kySRUPMpOhaGjL6mRJn8HFU1OxKY5bMqfZKUwTQc1mZkAjmGYaVOHFtQ==",
+ "dev": true,
+ "requires": {
+ "camelcase": "5.0.0",
+ "chalk": "2.4.2",
+ "leven": "2.1.0",
+ "mri": "1.1.4"
+ }
+ },
+ "basic-auth-parser": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/basic-auth-parser/-/basic-auth-parser-0.0.2.tgz",
+ "integrity": "sha1-zp5xp38jwSee7NJlmypGJEwVbkE=",
+ "dev": true
+ },
+ "camelcase": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz",
+ "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==",
+ "dev": true
+ },
+ "chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ }
+ },
+ "color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dev": true,
+ "requires": {
+ "color-name": "1.1.3"
+ }
+ },
+ "color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
+ "dev": true
+ },
+ "debug": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+ "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+ "dev": true,
+ "requires": {
+ "ms": "^2.1.1"
+ }
+ },
+ "escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+ "dev": true
+ },
+ "leven": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz",
+ "integrity": "sha1-wuep93IJTe6dNCAq6KzORoeHVYA=",
+ "dev": true
+ },
+ "mri": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz",
+ "integrity": "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==",
+ "dev": true
+ },
+ "ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "proxy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/proxy/-/proxy-1.0.2.tgz",
+ "integrity": "sha512-KNac2ueWRpjbUh77OAFPZuNdfEqNynm9DD4xHT14CccGpW8wKZwEkN0yjlb7X9G9Z9F55N0Q+1z+WfgAhwYdzQ==",
+ "dev": true,
+ "requires": {
+ "args": "5.0.1",
+ "basic-auth-parser": "0.0.2",
+ "debug": "^4.1.1"
+ }
+ },
+ "supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^3.0.0"
+ }
+ },
+ "tunnel": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
+ "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
+ "dev": true
+ }
+ }
+}
diff --git a/packages/http-client/package.json b/packages/http-client/package.json
new file mode 100644
index 00000000..f9db7e77
--- /dev/null
+++ b/packages/http-client/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@actions/http-client",
+ "version": "2.0.0",
+ "description": "Actions Http Client",
+ "keywords": [
+ "github",
+ "actions",
+ "http"
+ ],
+ "homepage": "https://github.com/actions/toolkit/tree/main/packages/http-client",
+ "license": "MIT",
+ "main": "lib/index.js",
+ "types": "lib/index.d.ts",
+ "directories": {
+ "lib": "lib",
+ "test": "__tests__"
+ },
+ "files": [
+ "lib",
+ "!.DS_Store"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/actions/toolkit.git",
+ "directory": "packages/github"
+ },
+ "scripts": {
+ "audit-moderate": "npm install && npm audit --json --audit-level=moderate > audit.json",
+ "test": "echo \"Error: run tests from root\" && exit 1",
+ "build": "tsc",
+ "format": "prettier --write **/*.ts",
+ "format-check": "prettier --check **/*.ts",
+ "tsc": "tsc"
+ },
+ "bugs": {
+ "url": "https://github.com/actions/toolkit/issues"
+ },
+ "devDependencies": {
+ "@types/tunnel": "0.0.3",
+ "proxy": "^1.0.1",
+ "tunnel": "0.0.6"
+ }
+}
diff --git a/packages/http-client/src/auth.ts b/packages/http-client/src/auth.ts
new file mode 100644
index 00000000..639adbe2
--- /dev/null
+++ b/packages/http-client/src/auth.ts
@@ -0,0 +1,86 @@
+import * as http from 'http'
+import * as ifm from './interfaces'
+import {HttpClientResponse} from './index'
+
+export class BasicCredentialHandler implements ifm.RequestHandler {
+ username: string
+ password: string
+
+ constructor(username: string, password: string) {
+ this.username = username
+ this.password = password
+ }
+
+ prepareRequest(options: http.RequestOptions): void {
+ if (!options.headers) {
+ throw Error('The request has no headers')
+ }
+ options.headers['Authorization'] = `Basic ${Buffer.from(
+ `${this.username}:${this.password}`
+ ).toString('base64')}`
+ }
+
+ // This handler cannot handle 401
+ canHandleAuthentication(): boolean {
+ return false
+ }
+
+ async handleAuthentication(): Promise {
+ throw new Error('not implemented')
+ }
+}
+
+export class BearerCredentialHandler implements ifm.RequestHandler {
+ token: string
+
+ constructor(token: string) {
+ this.token = token
+ }
+
+ // currently implements pre-authorization
+ // TODO: support preAuth = false where it hooks on 401
+ prepareRequest(options: http.RequestOptions): void {
+ if (!options.headers) {
+ throw Error('The request has no headers')
+ }
+ options.headers['Authorization'] = `Bearer ${this.token}`
+ }
+
+ // This handler cannot handle 401
+ canHandleAuthentication(): boolean {
+ return false
+ }
+
+ async handleAuthentication(): Promise {
+ throw new Error('not implemented')
+ }
+}
+
+export class PersonalAccessTokenCredentialHandler
+ implements ifm.RequestHandler {
+ token: string
+
+ constructor(token: string) {
+ this.token = token
+ }
+
+ // currently implements pre-authorization
+ // TODO: support preAuth = false where it hooks on 401
+ prepareRequest(options: http.RequestOptions): void {
+ if (!options.headers) {
+ throw Error('The request has no headers')
+ }
+ options.headers['Authorization'] = `Basic ${Buffer.from(
+ `PAT:${this.token}`
+ ).toString('base64')}`
+ }
+
+ // This handler cannot handle 401
+ canHandleAuthentication(): boolean {
+ return false
+ }
+
+ async handleAuthentication(): Promise {
+ throw new Error('not implemented')
+ }
+}
diff --git a/packages/http-client/src/index.ts b/packages/http-client/src/index.ts
new file mode 100644
index 00000000..f02c2754
--- /dev/null
+++ b/packages/http-client/src/index.ts
@@ -0,0 +1,773 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import * as http from 'http'
+import * as https from 'https'
+import * as ifm from './interfaces'
+import * as net from 'net'
+import * as pm from './proxy'
+import * as tunnel from 'tunnel'
+
+export enum HttpCodes {
+ OK = 200,
+ MultipleChoices = 300,
+ MovedPermanently = 301,
+ ResourceMoved = 302,
+ SeeOther = 303,
+ NotModified = 304,
+ UseProxy = 305,
+ SwitchProxy = 306,
+ TemporaryRedirect = 307,
+ PermanentRedirect = 308,
+ BadRequest = 400,
+ Unauthorized = 401,
+ PaymentRequired = 402,
+ Forbidden = 403,
+ NotFound = 404,
+ MethodNotAllowed = 405,
+ NotAcceptable = 406,
+ ProxyAuthenticationRequired = 407,
+ RequestTimeout = 408,
+ Conflict = 409,
+ Gone = 410,
+ TooManyRequests = 429,
+ InternalServerError = 500,
+ NotImplemented = 501,
+ BadGateway = 502,
+ ServiceUnavailable = 503,
+ GatewayTimeout = 504
+}
+
+export enum Headers {
+ Accept = 'accept',
+ ContentType = 'content-type'
+}
+
+export enum MediaTypes {
+ ApplicationJson = 'application/json'
+}
+
+/**
+ * Returns the proxy URL, depending upon the supplied url and proxy environment variables.
+ * @param serverUrl The server URL where the request will be sent. For example, https://api.github.com
+ */
+export function getProxyUrl(serverUrl: string): string {
+ const proxyUrl = pm.getProxyUrl(new URL(serverUrl))
+ return proxyUrl ? proxyUrl.href : ''
+}
+
+const HttpRedirectCodes: number[] = [
+ HttpCodes.MovedPermanently,
+ HttpCodes.ResourceMoved,
+ HttpCodes.SeeOther,
+ HttpCodes.TemporaryRedirect,
+ HttpCodes.PermanentRedirect
+]
+const HttpResponseRetryCodes: number[] = [
+ HttpCodes.BadGateway,
+ HttpCodes.ServiceUnavailable,
+ HttpCodes.GatewayTimeout
+]
+const RetryableHttpVerbs: string[] = ['OPTIONS', 'GET', 'DELETE', 'HEAD']
+const ExponentialBackoffCeiling = 10
+const ExponentialBackoffTimeSlice = 5
+
+export class HttpClientError extends Error {
+ constructor(message: string, statusCode: number) {
+ super(message)
+ this.name = 'HttpClientError'
+ this.statusCode = statusCode
+ Object.setPrototypeOf(this, HttpClientError.prototype)
+ }
+
+ statusCode: number
+ result?: any
+}
+
+export class HttpClientResponse {
+ constructor(message: http.IncomingMessage) {
+ this.message = message
+ }
+
+ message: http.IncomingMessage
+ async readBody(): Promise {
+ return new Promise(async resolve => {
+ let output = Buffer.alloc(0)
+
+ this.message.on('data', (chunk: Buffer) => {
+ output = Buffer.concat([output, chunk])
+ })
+
+ this.message.on('end', () => {
+ resolve(output.toString())
+ })
+ })
+ }
+}
+
+export function isHttps(requestUrl: string): boolean {
+ const parsedUrl: URL = new URL(requestUrl)
+ return parsedUrl.protocol === 'https:'
+}
+
+export class HttpClient {
+ userAgent: string | undefined
+ handlers: ifm.RequestHandler[]
+ requestOptions: ifm.RequestOptions | undefined
+
+ private _ignoreSslError = false
+ private _socketTimeout: number | undefined
+ private _allowRedirects = true
+ private _allowRedirectDowngrade = false
+ private _maxRedirects = 50
+ private _allowRetries = false
+ private _maxRetries = 1
+ private _agent: any
+ private _proxyAgent: any
+ private _keepAlive = false
+ private _disposed = false
+
+ constructor(
+ userAgent?: string,
+ handlers?: ifm.RequestHandler[],
+ requestOptions?: ifm.RequestOptions
+ ) {
+ this.userAgent = userAgent
+ this.handlers = handlers || []
+ this.requestOptions = requestOptions
+ if (requestOptions) {
+ if (requestOptions.ignoreSslError != null) {
+ this._ignoreSslError = requestOptions.ignoreSslError
+ }
+
+ this._socketTimeout = requestOptions.socketTimeout
+
+ if (requestOptions.allowRedirects != null) {
+ this._allowRedirects = requestOptions.allowRedirects
+ }
+
+ if (requestOptions.allowRedirectDowngrade != null) {
+ this._allowRedirectDowngrade = requestOptions.allowRedirectDowngrade
+ }
+
+ if (requestOptions.maxRedirects != null) {
+ this._maxRedirects = Math.max(requestOptions.maxRedirects, 0)
+ }
+
+ if (requestOptions.keepAlive != null) {
+ this._keepAlive = requestOptions.keepAlive
+ }
+
+ if (requestOptions.allowRetries != null) {
+ this._allowRetries = requestOptions.allowRetries
+ }
+
+ if (requestOptions.maxRetries != null) {
+ this._maxRetries = requestOptions.maxRetries
+ }
+ }
+ }
+
+ async options(
+ requestUrl: string,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise {
+ return this.request('OPTIONS', requestUrl, null, additionalHeaders || {})
+ }
+
+ async get(
+ requestUrl: string,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise {
+ return this.request('GET', requestUrl, null, additionalHeaders || {})
+ }
+
+ async del(
+ requestUrl: string,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise {
+ return this.request('DELETE', requestUrl, null, additionalHeaders || {})
+ }
+
+ async post(
+ requestUrl: string,
+ data: string,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise {
+ return this.request('POST', requestUrl, data, additionalHeaders || {})
+ }
+
+ async patch(
+ requestUrl: string,
+ data: string,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise {
+ return this.request('PATCH', requestUrl, data, additionalHeaders || {})
+ }
+
+ async put(
+ requestUrl: string,
+ data: string,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise {
+ return this.request('PUT', requestUrl, data, additionalHeaders || {})
+ }
+
+ async head(
+ requestUrl: string,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise {
+ return this.request('HEAD', requestUrl, null, additionalHeaders || {})
+ }
+
+ async sendStream(
+ verb: string,
+ requestUrl: string,
+ stream: NodeJS.ReadableStream,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise {
+ return this.request(verb, requestUrl, stream, additionalHeaders)
+ }
+
+ /**
+ * Gets a typed object from an endpoint
+ * Be aware that not found returns a null. Other errors (4xx, 5xx) reject the promise
+ */
+ async getJson(
+ requestUrl: string,
+ additionalHeaders: http.OutgoingHttpHeaders = {}
+ ): Promise> {
+ additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader(
+ additionalHeaders,
+ Headers.Accept,
+ MediaTypes.ApplicationJson
+ )
+ const res: HttpClientResponse = await this.get(
+ requestUrl,
+ additionalHeaders
+ )
+ return this._processResponse(res, this.requestOptions)
+ }
+
+ async postJson(
+ requestUrl: string,
+ obj: any,
+ additionalHeaders: http.OutgoingHttpHeaders = {}
+ ): Promise> {
+ const data: string = JSON.stringify(obj, null, 2)
+ additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader(
+ additionalHeaders,
+ Headers.Accept,
+ MediaTypes.ApplicationJson
+ )
+ additionalHeaders[Headers.ContentType] = this._getExistingOrDefaultHeader(
+ additionalHeaders,
+ Headers.ContentType,
+ MediaTypes.ApplicationJson
+ )
+ const res: HttpClientResponse = await this.post(
+ requestUrl,
+ data,
+ additionalHeaders
+ )
+ return this._processResponse(res, this.requestOptions)
+ }
+
+ async putJson(
+ requestUrl: string,
+ obj: any,
+ additionalHeaders: http.OutgoingHttpHeaders = {}
+ ): Promise> {
+ const data: string = JSON.stringify(obj, null, 2)
+ additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader(
+ additionalHeaders,
+ Headers.Accept,
+ MediaTypes.ApplicationJson
+ )
+ additionalHeaders[Headers.ContentType] = this._getExistingOrDefaultHeader(
+ additionalHeaders,
+ Headers.ContentType,
+ MediaTypes.ApplicationJson
+ )
+ const res: HttpClientResponse = await this.put(
+ requestUrl,
+ data,
+ additionalHeaders
+ )
+ return this._processResponse(res, this.requestOptions)
+ }
+
+ async patchJson(
+ requestUrl: string,
+ obj: any,
+ additionalHeaders: http.OutgoingHttpHeaders = {}
+ ): Promise> {
+ const data: string = JSON.stringify(obj, null, 2)
+ additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader(
+ additionalHeaders,
+ Headers.Accept,
+ MediaTypes.ApplicationJson
+ )
+ additionalHeaders[Headers.ContentType] = this._getExistingOrDefaultHeader(
+ additionalHeaders,
+ Headers.ContentType,
+ MediaTypes.ApplicationJson
+ )
+ const res: HttpClientResponse = await this.patch(
+ requestUrl,
+ data,
+ additionalHeaders
+ )
+ return this._processResponse(res, this.requestOptions)
+ }
+
+ /**
+ * Makes a raw http request.
+ * All other methods such as get, post, patch, and request ultimately call this.
+ * Prefer get, del, post and patch
+ */
+ async request(
+ verb: string,
+ requestUrl: string,
+ data: string | NodeJS.ReadableStream | null,
+ headers?: http.OutgoingHttpHeaders
+ ): Promise {
+ if (this._disposed) {
+ throw new Error('Client has already been disposed.')
+ }
+
+ const parsedUrl = new URL(requestUrl)
+ let info: ifm.RequestInfo = this._prepareRequest(verb, parsedUrl, headers)
+
+ // Only perform retries on reads since writes may not be idempotent.
+ const maxTries: number =
+ this._allowRetries && RetryableHttpVerbs.includes(verb)
+ ? this._maxRetries + 1
+ : 1
+ let numTries = 0
+
+ let response: HttpClientResponse | undefined
+ do {
+ response = await this.requestRaw(info, data)
+
+ // Check if it's an authentication challenge
+ if (
+ response &&
+ response.message &&
+ response.message.statusCode === HttpCodes.Unauthorized
+ ) {
+ let authenticationHandler: ifm.RequestHandler | undefined
+
+ for (const handler of this.handlers) {
+ if (handler.canHandleAuthentication(response)) {
+ authenticationHandler = handler
+ break
+ }
+ }
+
+ if (authenticationHandler) {
+ return authenticationHandler.handleAuthentication(this, info, data)
+ } else {
+ // We have received an unauthorized response but have no handlers to handle it.
+ // Let the response return to the caller.
+ return response
+ }
+ }
+
+ let redirectsRemaining: number = this._maxRedirects
+ while (
+ response.message.statusCode &&
+ HttpRedirectCodes.includes(response.message.statusCode) &&
+ this._allowRedirects &&
+ redirectsRemaining > 0
+ ) {
+ const redirectUrl: string | undefined =
+ response.message.headers['location']
+ if (!redirectUrl) {
+ // if there's no location to redirect to, we won't
+ break
+ }
+ const parsedRedirectUrl = new URL(redirectUrl)
+ if (
+ parsedUrl.protocol === 'https:' &&
+ parsedUrl.protocol !== parsedRedirectUrl.protocol &&
+ !this._allowRedirectDowngrade
+ ) {
+ throw new Error(
+ 'Redirect from HTTPS to HTTP protocol. This downgrade is not allowed for security reasons. If you want to allow this behavior, set the allowRedirectDowngrade option to true.'
+ )
+ }
+
+ // we need to finish reading the response before reassigning response
+ // which will leak the open socket.
+ await response.readBody()
+
+ // strip authorization header if redirected to a different hostname
+ if (parsedRedirectUrl.hostname !== parsedUrl.hostname) {
+ for (const header in headers) {
+ // header names are case insensitive
+ if (header.toLowerCase() === 'authorization') {
+ delete headers[header]
+ }
+ }
+ }
+
+ // let's make the request with the new redirectUrl
+ info = this._prepareRequest(verb, parsedRedirectUrl, headers)
+ response = await this.requestRaw(info, data)
+ redirectsRemaining--
+ }
+
+ if (
+ !response.message.statusCode ||
+ !HttpResponseRetryCodes.includes(response.message.statusCode)
+ ) {
+ // If not a retry code, return immediately instead of retrying
+ return response
+ }
+
+ numTries += 1
+
+ if (numTries < maxTries) {
+ await response.readBody()
+ await this._performExponentialBackoff(numTries)
+ }
+ } while (numTries < maxTries)
+
+ return response
+ }
+
+ /**
+ * Needs to be called if keepAlive is set to true in request options.
+ */
+ dispose(): void {
+ if (this._agent) {
+ this._agent.destroy()
+ }
+
+ this._disposed = true
+ }
+
+ /**
+ * Raw request.
+ * @param info
+ * @param data
+ */
+ async requestRaw(
+ info: ifm.RequestInfo,
+ data: string | NodeJS.ReadableStream | null
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ function callbackForResult(err?: Error, res?: HttpClientResponse): void {
+ if (err) {
+ reject(err)
+ } else if (!res) {
+ // If `err` is not passed, then `res` must be passed.
+ reject(new Error('Unknown error'))
+ } else {
+ resolve(res)
+ }
+ }
+
+ this.requestRawWithCallback(info, data, callbackForResult)
+ })
+ }
+
+ /**
+ * Raw request with callback.
+ * @param info
+ * @param data
+ * @param onResult
+ */
+ requestRawWithCallback(
+ info: ifm.RequestInfo,
+ data: string | NodeJS.ReadableStream | null,
+ onResult: (err?: Error, res?: HttpClientResponse) => void
+ ): void {
+ if (typeof data === 'string') {
+ if (!info.options.headers) {
+ info.options.headers = {}
+ }
+ info.options.headers['Content-Length'] = Buffer.byteLength(data, 'utf8')
+ }
+
+ let callbackCalled = false
+ function handleResult(err?: Error, res?: HttpClientResponse): void {
+ if (!callbackCalled) {
+ callbackCalled = true
+ onResult(err, res)
+ }
+ }
+
+ const req: http.ClientRequest = info.httpModule.request(
+ info.options,
+ (msg: http.IncomingMessage) => {
+ const res: HttpClientResponse = new HttpClientResponse(msg)
+ handleResult(undefined, res)
+ }
+ )
+
+ let socket: net.Socket
+ req.on('socket', sock => {
+ socket = sock
+ })
+
+ // If we ever get disconnected, we want the socket to timeout eventually
+ req.setTimeout(this._socketTimeout || 3 * 60000, () => {
+ if (socket) {
+ socket.end()
+ }
+ handleResult(new Error(`Request timeout: ${info.options.path}`))
+ })
+
+ req.on('error', function(err) {
+ // err has statusCode property
+ // res should have headers
+ handleResult(err)
+ })
+
+ if (data && typeof data === 'string') {
+ req.write(data, 'utf8')
+ }
+
+ if (data && typeof data !== 'string') {
+ data.on('close', function() {
+ req.end()
+ })
+
+ data.pipe(req)
+ } else {
+ req.end()
+ }
+ }
+
+ /**
+ * Gets an http agent. This function is useful when you need an http agent that handles
+ * routing through a proxy server - depending upon the url and proxy environment variables.
+ * @param serverUrl The server URL where the request will be sent. For example, https://api.github.com
+ */
+ getAgent(serverUrl: string): http.Agent {
+ const parsedUrl = new URL(serverUrl)
+ return this._getAgent(parsedUrl)
+ }
+
+ private _prepareRequest(
+ method: string,
+ requestUrl: URL,
+ headers?: http.OutgoingHttpHeaders
+ ): ifm.RequestInfo {
+ const info: ifm.RequestInfo = {}
+
+ info.parsedUrl = requestUrl
+ const usingSsl: boolean = info.parsedUrl.protocol === 'https:'
+ info.httpModule = usingSsl ? https : http
+ const defaultPort: number = usingSsl ? 443 : 80
+
+ info.options = {}
+ info.options.host = info.parsedUrl.hostname
+ info.options.port = info.parsedUrl.port
+ ? parseInt(info.parsedUrl.port)
+ : defaultPort
+ info.options.path =
+ (info.parsedUrl.pathname || '') + (info.parsedUrl.search || '')
+ info.options.method = method
+ info.options.headers = this._mergeHeaders(headers)
+ if (this.userAgent != null) {
+ info.options.headers['user-agent'] = this.userAgent
+ }
+
+ info.options.agent = this._getAgent(info.parsedUrl)
+
+ // gives handlers an opportunity to participate
+ if (this.handlers) {
+ for (const handler of this.handlers) {
+ handler.prepareRequest(info.options)
+ }
+ }
+
+ return info
+ }
+
+ private _mergeHeaders(
+ headers?: http.OutgoingHttpHeaders
+ ): http.OutgoingHttpHeaders {
+ if (this.requestOptions && this.requestOptions.headers) {
+ return Object.assign(
+ {},
+ lowercaseKeys(this.requestOptions.headers),
+ lowercaseKeys(headers || {})
+ )
+ }
+
+ return lowercaseKeys(headers || {})
+ }
+
+ private _getExistingOrDefaultHeader(
+ additionalHeaders: http.OutgoingHttpHeaders,
+ header: string,
+ _default: string
+ ): string | number | string[] {
+ let clientHeader: string | undefined
+ if (this.requestOptions && this.requestOptions.headers) {
+ clientHeader = lowercaseKeys(this.requestOptions.headers)[header]
+ }
+ return additionalHeaders[header] || clientHeader || _default
+ }
+
+ private _getAgent(parsedUrl: URL): http.Agent {
+ let agent
+ const proxyUrl = pm.getProxyUrl(parsedUrl)
+ const useProxy = proxyUrl && proxyUrl.hostname
+
+ if (this._keepAlive && useProxy) {
+ agent = this._proxyAgent
+ }
+
+ if (this._keepAlive && !useProxy) {
+ agent = this._agent
+ }
+
+ // if agent is already assigned use that agent.
+ if (agent) {
+ return agent
+ }
+
+ const usingSsl = parsedUrl.protocol === 'https:'
+ let maxSockets = 100
+ if (this.requestOptions) {
+ maxSockets = this.requestOptions.maxSockets || http.globalAgent.maxSockets
+ }
+
+ // This is `useProxy` again, but we need to check `proxyURl` directly for TypeScripts's flow analysis.
+ if (proxyUrl && proxyUrl.hostname) {
+ const agentOptions = {
+ maxSockets,
+ keepAlive: this._keepAlive,
+ proxy: {
+ ...((proxyUrl.username || proxyUrl.password) && {
+ proxyAuth: `${proxyUrl.username}:${proxyUrl.password}`
+ }),
+ host: proxyUrl.hostname,
+ port: proxyUrl.port
+ }
+ }
+
+ let tunnelAgent: Function
+ const overHttps = proxyUrl.protocol === 'https:'
+ if (usingSsl) {
+ tunnelAgent = overHttps ? tunnel.httpsOverHttps : tunnel.httpsOverHttp
+ } else {
+ tunnelAgent = overHttps ? tunnel.httpOverHttps : tunnel.httpOverHttp
+ }
+
+ agent = tunnelAgent(agentOptions)
+ this._proxyAgent = agent
+ }
+
+ // if reusing agent across request and tunneling agent isn't assigned create a new agent
+ if (this._keepAlive && !agent) {
+ const options = {keepAlive: this._keepAlive, maxSockets}
+ agent = usingSsl ? new https.Agent(options) : new http.Agent(options)
+ this._agent = agent
+ }
+
+ // if not using private agent and tunnel agent isn't setup then use global agent
+ if (!agent) {
+ agent = usingSsl ? https.globalAgent : http.globalAgent
+ }
+
+ if (usingSsl && this._ignoreSslError) {
+ // we don't want to set NODE_TLS_REJECT_UNAUTHORIZED=0 since that will affect request for entire process
+ // http.RequestOptions doesn't expose a way to modify RequestOptions.agent.options
+ // we have to cast it to any and change it directly
+ agent.options = Object.assign(agent.options || {}, {
+ rejectUnauthorized: false
+ })
+ }
+
+ return agent
+ }
+
+ private async _performExponentialBackoff(retryNumber: number): Promise {
+ retryNumber = Math.min(ExponentialBackoffCeiling, retryNumber)
+ const ms: number = ExponentialBackoffTimeSlice * Math.pow(2, retryNumber)
+ return new Promise(resolve => setTimeout(() => resolve(), ms))
+ }
+
+ private async _processResponse(
+ res: HttpClientResponse,
+ options?: ifm.RequestOptions
+ ): Promise> {
+ return new Promise>(async (resolve, reject) => {
+ const statusCode = res.message.statusCode || 0
+
+ const response: ifm.TypedResponse = {
+ statusCode,
+ result: null,
+ headers: {}
+ }
+
+ // not found leads to null obj returned
+ if (statusCode === HttpCodes.NotFound) {
+ resolve(response)
+ }
+
+ // get the result from the body
+
+ function dateTimeDeserializer(key: any, value: any): any {
+ if (typeof value === 'string') {
+ const a = new Date(value)
+ if (!isNaN(a.valueOf())) {
+ return a
+ }
+ }
+
+ return value
+ }
+
+ let obj: any
+ let contents: string | undefined
+
+ try {
+ contents = await res.readBody()
+ if (contents && contents.length > 0) {
+ if (options && options.deserializeDates) {
+ obj = JSON.parse(contents, dateTimeDeserializer)
+ } else {
+ obj = JSON.parse(contents)
+ }
+
+ response.result = obj
+ }
+
+ response.headers = res.message.headers
+ } catch (err) {
+ // Invalid resource (contents not json); leaving result obj null
+ }
+
+ // note that 3xx redirects are handled by the http layer.
+ if (statusCode > 299) {
+ let msg: string
+
+ // if exception/error in body, attempt to get better error
+ if (obj && obj.message) {
+ msg = obj.message
+ } else if (contents && contents.length > 0) {
+ // it may be the case that the exception is in the body message as string
+ msg = contents
+ } else {
+ msg = `Failed request: (${statusCode})`
+ }
+
+ const err = new HttpClientError(msg, statusCode)
+ err.result = response.result
+
+ reject(err)
+ } else {
+ resolve(response)
+ }
+ })
+ }
+}
+
+const lowercaseKeys = (obj: {[index: string]: any}): any =>
+ Object.keys(obj).reduce((c: any, k) => ((c[k.toLowerCase()] = obj[k]), c), {})
diff --git a/packages/http-client/src/interfaces.ts b/packages/http-client/src/interfaces.ts
new file mode 100644
index 00000000..96b0fec7
--- /dev/null
+++ b/packages/http-client/src/interfaces.ts
@@ -0,0 +1,91 @@
+import * as http from 'http'
+import * as https from 'https'
+import {HttpClientResponse} from './index'
+
+export interface HttpClient {
+ options(
+ requestUrl: string,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise
+ get(
+ requestUrl: string,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise
+ del(
+ requestUrl: string,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise
+ post(
+ requestUrl: string,
+ data: string,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise
+ patch(
+ requestUrl: string,
+ data: string,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise
+ put(
+ requestUrl: string,
+ data: string,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise
+ sendStream(
+ verb: string,
+ requestUrl: string,
+ stream: NodeJS.ReadableStream,
+ additionalHeaders?: http.OutgoingHttpHeaders
+ ): Promise
+ request(
+ verb: string,
+ requestUrl: string,
+ data: string | NodeJS.ReadableStream,
+ headers: http.OutgoingHttpHeaders
+ ): Promise
+ requestRaw(
+ info: RequestInfo,
+ data: string | NodeJS.ReadableStream
+ ): Promise
+ requestRawWithCallback(
+ info: RequestInfo,
+ data: string | NodeJS.ReadableStream,
+ onResult: (err?: Error, res?: HttpClientResponse) => void
+ ): void
+}
+
+export interface RequestHandler {
+ prepareRequest(options: http.RequestOptions): void
+ canHandleAuthentication(response: HttpClientResponse): boolean
+ handleAuthentication(
+ httpClient: HttpClient,
+ requestInfo: RequestInfo,
+ data: string | NodeJS.ReadableStream | null
+ ): Promise
+}
+
+export interface RequestInfo {
+ options: http.RequestOptions
+ parsedUrl: URL
+ httpModule: typeof http | typeof https
+}
+
+export interface RequestOptions {
+ headers?: http.OutgoingHttpHeaders
+ socketTimeout?: number
+ ignoreSslError?: boolean
+ allowRedirects?: boolean
+ allowRedirectDowngrade?: boolean
+ maxRedirects?: number
+ maxSockets?: number
+ keepAlive?: boolean
+ deserializeDates?: boolean
+ // Allows retries only on Read operations (since writes may not be idempotent)
+ allowRetries?: boolean
+ maxRetries?: number
+}
+
+export interface TypedResponse {
+ statusCode: number
+ result: T | null
+ headers: http.IncomingHttpHeaders
+}
diff --git a/packages/http-client/src/proxy.ts b/packages/http-client/src/proxy.ts
new file mode 100644
index 00000000..f13409b5
--- /dev/null
+++ b/packages/http-client/src/proxy.ts
@@ -0,0 +1,60 @@
+export function getProxyUrl(reqUrl: URL): URL | undefined {
+ const usingSsl = reqUrl.protocol === 'https:'
+
+ if (checkBypass(reqUrl)) {
+ return undefined
+ }
+
+ const proxyVar = (() => {
+ if (usingSsl) {
+ return process.env['https_proxy'] || process.env['HTTPS_PROXY']
+ } else {
+ return process.env['http_proxy'] || process.env['HTTP_PROXY']
+ }
+ })()
+
+ if (proxyVar) {
+ return new URL(proxyVar)
+ } else {
+ return undefined
+ }
+}
+
+export function checkBypass(reqUrl: URL): boolean {
+ if (!reqUrl.hostname) {
+ return false
+ }
+
+ const noProxy = process.env['no_proxy'] || process.env['NO_PROXY'] || ''
+ if (!noProxy) {
+ return false
+ }
+
+ // Determine the request port
+ let reqPort: number | undefined
+ if (reqUrl.port) {
+ reqPort = Number(reqUrl.port)
+ } else if (reqUrl.protocol === 'http:') {
+ reqPort = 80
+ } else if (reqUrl.protocol === 'https:') {
+ reqPort = 443
+ }
+
+ // Format the request hostname and hostname with port
+ const upperReqHosts = [reqUrl.hostname.toUpperCase()]
+ if (typeof reqPort === 'number') {
+ upperReqHosts.push(`${upperReqHosts[0]}:${reqPort}`)
+ }
+
+ // Compare request host against noproxy
+ for (const upperNoProxyItem of noProxy
+ .split(',')
+ .map(x => x.trim().toUpperCase())
+ .filter(x => x)) {
+ if (upperReqHosts.some(x => x === upperNoProxyItem)) {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/packages/http-client/tsconfig.json b/packages/http-client/tsconfig.json
new file mode 100644
index 00000000..4abc2b1c
--- /dev/null
+++ b/packages/http-client/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./lib",
+ "rootDir": "./src",
+ "moduleResolution": "node"
+ },
+ "include": [
+ "./src"
+ ]
+}
\ No newline at end of file
diff --git a/scripts/create-package b/scripts/create-package
index ed38d73a..29d07feb 100755
--- a/scripts/create-package
+++ b/scripts/create-package
@@ -9,5 +9,5 @@ if [[ -z "$name" ]]; then
exit 1
fi
-lerna create @actions/$name
-cp packages/toolkit/tsconfig.json packages/$name/tsconfig.json
\ No newline at end of file
+npx lerna create @actions/$name
+cp packages/core/tsconfig.json packages/$name/tsconfig.json
\ No newline at end of file