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