@adobe/fetch
Version:
Light-weight Fetch implementation transparently supporting both HTTP/1(.1) and HTTP/2
237 lines (222 loc) • 7.92 kB
JavaScript
/*
* 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 };