UNPKG

@adobe/fetch

Version:

Light-weight Fetch implementation transparently supporting both HTTP/1(.1) and HTTP/2

237 lines (222 loc) 7.92 kB
/* * Copyright 2020 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ import http from 'http'; import https from 'https'; import { Readable } from 'stream'; import debugFactory from 'debug'; import { RequestAbortedError } from './errors.js'; import { decodeStream } from '../common/utils.js'; const debug = debugFactory('adobe/fetch:h1'); const getAgent = (ctx, protocol) => { // getAgent is synchronous, no need for lock/mutex const { h1, options: { h1: opts, rejectUnauthorized } } = ctx; if (protocol === 'https:') { // secure http if (h1.httpsAgent) { return h1.httpsAgent; } // use agent if either h1 options or rejectUnauthorized context option was specified if (opts || typeof rejectUnauthorized === 'boolean') { h1.httpsAgent = new https.Agent(typeof rejectUnauthorized === 'boolean' ? { ...(opts || {}), rejectUnauthorized } : opts); return h1.httpsAgent; } // use default (global) agent /* c8 ignore next 13 */ /* => code coverage depends on the node version */ if (https.globalAgent.keepAlive) /* node >= 19 */ { // As of Node.js v19 the global agent has keep-alive enabled by default: // https://nodejs.org/api/http.html#class-httpagent // https://github.com/nodejs/node/issues/37184 // https://github.com/nodejs/node/pull/43522/files#diff-494d2deee304c672124ecd82d090283595fd3d8c5a80a1825d972a2d229e4944L334-R334 // In order to guarantee consistent behavior across node versions we // always create a new agent with keep-alive disabled on Node.js v19+. h1.httpsAgent = new https.Agent({ keepAlive: false }); return h1.httpsAgent; } else /* node <= 18 */ { return undefined; } } else { // plain http if (h1.httpAgent) { return h1.httpAgent; } if (opts) { h1.httpAgent = new http.Agent(opts); return h1.httpAgent; } // use default (global) agent /* c8 ignore next 13 */ /* => code coverage depends on the node version */ if (http.globalAgent.keepAlive) /* node >= 19 */ { // As of Node.js v19 the global agent has keep-alive enabled by default: // https://nodejs.org/api/http.html#class-httpagent // https://github.com/nodejs/node/issues/37184 // https://github.com/nodejs/node/pull/43522/files#diff-494d2deee304c672124ecd82d090283595fd3d8c5a80a1825d972a2d229e4944L334-R334 // In order to guarantee consistent behavior across node versions we // always create a new agent with keep-alive disabled on Node.js v19+. h1.httpAgent = new http.Agent({ keepAlive: false }); return h1.httpAgent; } else /* node <= 18 */ { return undefined; } } }; const setupContext = (ctx) => { // const { options: { h1: opts } } = ctx; ctx.h1 = {}; // custom agents will be lazily instantiated }; const resetContext = async ({ h1 }) => { if (h1.httpAgent) { debug('resetContext: destroying httpAgent'); h1.httpAgent.destroy(); // eslint-disable-next-line no-param-reassign delete h1.httpAgent; } if (h1.httpsAgent) { debug('resetContext: destroying httpsAgent'); h1.httpsAgent.destroy(); // eslint-disable-next-line no-param-reassign delete h1.httpsAgent; } }; const createResponse = (incomingMessage, decode, onError) => { const { statusCode, statusMessage, httpVersion, httpVersionMajor, httpVersionMinor, headers, // header names are always lower-cased } = incomingMessage; const readable = decode ? decodeStream(statusCode, headers, incomingMessage, onError) : incomingMessage; const decoded = !!(decode && readable !== incomingMessage); return { statusCode, statusText: statusMessage, httpVersion, httpVersionMajor, httpVersionMinor, headers, readable, decoded, }; }; const h1Request = async (ctx, url, options) => { const { request } = url.protocol === 'https:' ? https : http; const agent = getAgent(ctx, url.protocol); const opts = { ...options, agent }; const { socket, body } = opts; if (socket) { // we've got a socket from initial protocol negotiation via ALPN delete opts.socket; /* c8 ignore next 27 */ if (!socket.assigned) { socket.assigned = true; // reuse socket for actual request if (agent) { // if there's an agent we need to override the agent's createConnection opts.agent = new Proxy(agent, { get: (target, property) => { if (property === 'createConnection' && !socket.inUse) { return (_connectOptions, cb) => { debug(`agent reusing socket #${socket.id} (${socket.servername})`); socket.inUse = true; cb(null, socket); }; } else { return target[property]; } }, }); } else { // no agent, provide createConnection function in options opts.createConnection = (_connectOptions, cb) => { debug(`reusing socket #${socket.id} (${socket.servername})`); socket.inUse = true; cb(null, socket); }; } } } return new Promise((resolve, reject) => { debug(`${opts.method} ${url.href}`); let req; // intercept abort signal in order to cancel request const { signal } = opts; const onAbortSignal = () => { // deregister from signal signal.removeEventListener('abort', onAbortSignal); /* c8 ignore next 5 */ if (socket && !socket.inUse) { // we have no use for the passed socket debug(`discarding redundant socket used for ALPN: #${socket.id} ${socket.servername}`); socket.destroy(); } reject(new RequestAbortedError()); if (req) { req.abort(); } }; if (signal) { if (signal.aborted) { reject(new RequestAbortedError()); return; } signal.addEventListener('abort', onAbortSignal); } req = request(url, opts); req.once('response', (res) => { if (signal) { signal.removeEventListener('abort', onAbortSignal); } /* c8 ignore next 5 */ if (socket && !socket.inUse) { // we have no use for the passed socket debug(`discarding redundant socket used for ALPN: #${socket.id} ${socket.servername}`); socket.destroy(); } resolve(createResponse(res, opts.decode, reject)); }); req.once('error', (err) => { // error occured during the request if (signal) { signal.removeEventListener('abort', onAbortSignal); } /* c8 ignore next 5 */ if (socket && !socket.inUse) { // we have no use for the passed socket debug(`discarding redundant socket used for ALPN: #${socket.id} ${socket.servername}`); socket.destroy(); } /* c8 ignore next 6 */ if (!req.aborted) { debug(`${opts.method} ${url.href} failed with: ${err.message}`); // TODO: better call req.destroy(err) instead of req.abort() ? req.abort(); reject(err); } }); // send request body? if (body instanceof Readable) { body.pipe(req); } else { if (body) { req.write(body); } req.end(); } }); }; export default { request: h1Request, setupContext, resetContext };