Skip to content

Commit

Permalink
Added support for HTTP/2 (#93)
Browse files Browse the repository at this point in the history
  • Loading branch information
parthverma1 authored Jul 23, 2024
1 parent b61949e commit 0cd3902
Show file tree
Hide file tree
Showing 55 changed files with 4,034 additions and 440 deletions.
25 changes: 8 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3

- name: Use Node.js 10.x
- name: Use Node.js 16.x
uses: actions/setup-node@v3
with:
node-version: 10.x
node-version: 16.x

- name: Install
run: npm install
- name: Install with legacy peer deps
run: npm install --legacy-peer-deps

- name: Run browser tests
run: npm run test-browser
Expand All @@ -55,11 +55,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node-version: [6, 8]
include:
- node-version: 10
# todo: enable coverage after figuring out why test-cov step is failing
coverage: false
node-version: [16, 18]

steps:
- name: Checkout repository
Expand All @@ -70,13 +66,8 @@ jobs:
with:
node-version: ${{ matrix.node-version }}

- name: Install
run: npm install
- name: Install with legacy peer deps
run: npm install --legacy-peer-deps

- if: ${{ ! matrix.coverage }}
name: Run tests
- name: Run tests and upload coverage
run: npm run test-ci

- if: ${{ matrix.coverage }}
name: Run tests and upload coverage
run: npm run test-cov && npx codecov && cat ./coverage/lcov.info | npx coveralls
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,8 @@ request.get({
});
```

NOTE: If using `protocolVersion` anything other than `http1`, make sure that the agent is capable of handling requests with the specified protocol version. For example, if `protocolVersion` is set to `http1`, the agent should be an instance of `http.Agent`.

### Using `options.verbose`

Using this option the debug object holds low level request response information like remote address, negotiated ciphers etc. Example debug object:
Expand Down Expand Up @@ -883,7 +885,7 @@ The first argument can be either a `url` or an `options` object. The only requir
- `baseUrl` - fully qualified uri string used as the base url. Most useful with `request.defaults`, for example when you want to do many requests to the same domain. If `baseUrl` is `https://example.com/api/`, then requesting `/end/point?test=true` will fetch `https://example.com/api/end/point?test=true`. When `baseUrl` is given, `uri` must also be a string.
- `method` - http method (default: `"GET"`)
- `headers` - http headers (default: `{}`)

- `protocolVersion` - HTTP Protocol Version to use. Can be one of `auto|http1|http2` (default: `http1`). Is overridden to `http1` when sending a http request, using proxy, or running in a browser environment.
---

- `qs` - object containing querystring values to be appended to the `uri`
Expand Down
20 changes: 19 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ function initParams (uri, options, callback) {
callback = options
}

var params = {}
var params = {protocolVersion: 'http1'}

if (options !== null && typeof options === 'object') {
extend(params, options, {uri: uri})
} else if (typeof uri === 'string') {
Expand All @@ -33,6 +34,23 @@ function initParams (uri, options, callback) {
}

params.callback = callback || params.callback

// Disable http/2 when using custom agents that don't handle different versions separately
if (params.agents && !(params.agents.http1 || params.agents.auto || params.agents.http2)) {
params.protocolVersion = 'http1'
}

// Disable http/2 when using proxy or tunnels
// TODO: Remove this when http2 supports proxy and tunneling
if (params.tunnel || params.proxy) {
params.protocolVersion = 'http1'
}

// Disable flow when running in browser
if (typeof window !== 'undefined' && window.XMLHttpRequest) {
params.protocolVersion = 'http1'
}

return params
}

Expand Down
212 changes: 212 additions & 0 deletions lib/autohttp/agent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
const { Agent: Http2Agent } = require('../http2')
const https = require('https')
const tls = require('tls')
const { EventEmitter } = require('events')
const net = require('net')
const { getName: getSocketName } = require('../autohttp/requestName')

// All valid options defined at https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids
const supportedProtocols = ['h2', 'http/1.1', 'http/1.0', 'http/0.9']

// Referenced from https://github.com/nodejs/node/blob/0bf200b49a9a6eacdea6d5e5939cc2466506d532/lib/_http_agent.js#L350
function calculateServerName (options) {
let servername = options.host || ''
const hostHeader = options.headers && options.headers.host

if (hostHeader) {
if (typeof hostHeader !== 'string') {
throw new TypeError(
'host header content must be a string, received' + hostHeader
)
}

// abc => abc
// abc:123 => abc
// [::1] => ::1
// [::1]:123 => ::1
if (hostHeader.startsWith('[')) {
const index = hostHeader.indexOf(']')
if (index === -1) {
// Leading '[', but no ']'. Need to do something...
servername = hostHeader
} else {
servername = hostHeader.substring(1, index)
}
} else {
servername = hostHeader.split(':', 1)[0]
}
}
// Don't implicitly set invalid (IP) servernames.
if (net.isIP(servername)) servername = ''
return servername
}

class AutoHttp2Agent extends EventEmitter {
constructor (options) {
super()
this.http2Agent = new Http2Agent(options)
this.httpsAgent = new https.Agent(options)
this.ALPNCache = new Map()
this.options = options
this.defaultPort = 443
}

createConnection (
req,
reqOptions,
cb,
socketCb
) {
const options = {
...reqOptions,
...this.options,
port: Number(reqOptions.port || this.options.port || this.defaultPort),
host: reqOptions.hostname || reqOptions.host || 'localhost'
}

// check if ALPN is cached
const name = getSocketName(options)
const [protocol, cachedSocket] = this.ALPNCache.get(name) || []

if (!protocol || !cachedSocket || cachedSocket.closed || cachedSocket.destroyed) {
// No cache exists or the initial socket used to establish the connection has been closed. Perform ALPN again.
this.ALPNCache.delete(name)
this.createNewSocketConnection(req, options, cb, socketCb)
return
}

// No need to pass the cachedSocket since the respective protocol's agents will reuse the socket that was initially
// passed during ALPN Negotiation
if (protocol === 'h2') {
const http2Options = {
...options,
path: options.socketPath
}

let connection
try {
const uri = options.uri
connection = this.http2Agent.createConnection(req, uri, http2Options)
} catch (e) {
cb(e)
connection && connection.socket && socketCb(connection.socket)
return
}

cb(null, 'http2', connection)
socketCb(connection.socket)

return
}

const http1RequestOptions = {
...options,
agent: this.httpsAgent
}

let request
try {
request = https.request(http1RequestOptions)
} catch (e) {
cb(e)
return
}

request.on('socket', (socket) => socketCb(socket))
cb(null, 'http1', request)
}

createNewSocketConnection (req, options, cb, socketCb) {
const uri = options.uri
const name = getSocketName(options)

const socket = tls.connect({
...options,
path: options.socketPath,
ALPNProtocols: supportedProtocols,
servername: options.servername || calculateServerName(options)
})
socketCb(socket)

const socketConnectionErrorHandler = (e) => {
cb(e)
}
socket.on('error', socketConnectionErrorHandler)

socket.once('secureConnect', () => {
socket.removeListener('error', socketConnectionErrorHandler)

const protocol = socket.alpnProtocol

if (!protocol) {
cb(socket.authorizationError)
socket.end()
return
}

if (!supportedProtocols.includes(protocol)) {
cb(new Error('Unknown protocol' + protocol))
return
}

// Update the cache
this.ALPNCache.set(name, [protocol, socket])

socket.once('close', () => {
// Clean the cache when the socket closes
this.ALPNCache.delete(name)
})

if (protocol === 'h2') {
const http2Options = {
...options,
path: options.socketPath
}
try {
const connection = this.http2Agent.createConnection(
req,
uri,
http2Options,
socket
)
cb(null, 'http2', connection)
} catch (e) {
cb(e)
}
return
}

// Protocol is http1, using the built in agent
// We need to release all free sockets so that new connection is created using the overridden createconnection
// forcing the agent to reuse the socket used for alpn

// This reassignment works, since all code so far is sync, and happens in the same tick, hence there will be no
// race conditions
const oldCreateConnection = this.httpsAgent.createConnection

this.httpsAgent.createConnection = () => {
return socket
}

const http1RequestOptions = {
...options,
agent: this.httpsAgent
}
let request
try {
request = https.request(http1RequestOptions)
} catch (e) {
cb(e)
return
} finally {
this.httpsAgent.createConnection = oldCreateConnection
}
cb(null, 'http1', request)
})
}
}

module.exports = {
AutoHttp2Agent,
globalAgent: new AutoHttp2Agent({})
}
40 changes: 40 additions & 0 deletions lib/autohttp/headerValidations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const {constants = {}} = require('http2')

// Referenced from https://github.com/nodejs/node/blob/0bf200b49a9a6eacdea6d5e5939cc2466506d532/lib/internal/http2/util.js#L107
const kValidPseudoHeaders = new Set([
constants.HTTP2_HEADER_STATUS,
constants.HTTP2_HEADER_METHOD,
constants.HTTP2_HEADER_AUTHORITY,
constants.HTTP2_HEADER_SCHEME,
constants.HTTP2_HEADER_PATH
])

// Referenced from https://github.com/nodejs/node/blob/0bf200b49a9a6eacdea6d5e5939cc2466506d532/lib/internal/http2/util.js#L573
function assertValidPseudoHeader (header) {
if (!kValidPseudoHeaders.has(header)) {
throw new Error('Invalid PseudoHeader ' + header)
}
}

// Referenced from https://github.com/nodejs/node/blob/0bf200b49a9a6eacdea6d5e5939cc2466506d532/lib/_http_common.js#L206
const tokenRegExp = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/
function checkIsHttpToken (token) {
return RegExp(tokenRegExp).exec(token) !== null
}

// Referenced from https://github.com/nodejs/node/blob/0bf200b49a9a6eacdea6d5e5939cc2466506d532/lib/internal/http2/core.js#L1763
function validateRequestHeaders (headers) {
if (headers !== null && headers !== undefined) {
const keys = Object.keys(headers)
for (let i = 0; i < keys.length; i++) {
const header = keys[i]
if (header[0] === ':') {
assertValidPseudoHeader(header)
} else if (header && !checkIsHttpToken(header)) { throw new Error('Invalid HTTP Token: Header name' + header) }
}
}
}

module.exports = {
validateRequestHeaders
}
8 changes: 8 additions & 0 deletions lib/autohttp/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const { AutoHttp2Agent, globalAgent } = require('./agent')
const { request } = require('./request')

module.exports = {
Agent: AutoHttp2Agent,
request,
globalAgent
}
Loading

0 comments on commit 0cd3902

Please sign in to comment.