@adobe/fetch
Version:
Light-weight Fetch implementation transparently supporting both HTTP/1(.1) and HTTP/2
341 lines (300 loc) • 10.3 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 { types } from 'util';
import { Readable } from 'stream';
import tls from 'tls';
import LRUCache from 'lru-cache';
import debugFactory from 'debug';
import { RequestAbortedError } from './errors.js';
import h1 from './h1.js';
import h2 from './h2.js';
import lock from './lock.js';
import { isFormData, FormDataSerializer } from '../common/formData.js';
import { isPlainObject } from '../common/utils.js';
import pkg from '../package.cjs';
const { version } = pkg;
const { isAnyArrayBuffer } = types;
const debug = debugFactory('adobe/fetch:core');
const ALPN_HTTP2 = 'h2';
const ALPN_HTTP2C = 'h2c';
const ALPN_HTTP1_0 = 'http/1.0';
const ALPN_HTTP1_1 = 'http/1.1';
// context option defaults
const ALPN_CACHE_SIZE = 100; // # of entries
const ALPN_CACHE_TTL = 60 * 60 * 1000; // (ms): 1h
const ALPN_PROTOCOLS = [ALPN_HTTP2, ALPN_HTTP1_1, ALPN_HTTP1_0];
const DEFAULT_USER_AGENT = `adobe-fetch/${version}`;
// request option defaults
const DEFAULT_OPTIONS = {
method: 'GET',
compress: true,
decode: true,
};
let socketIdCounter = 0;
const connectionLock = lock();
const connectTLS = (url, options) => new Promise((resolve, reject) => {
// intercept abort signal in order to cancel connect
const { signal } = options;
let socket;
const onAbortSignal = () => {
signal.removeEventListener('abort', onAbortSignal);
const err = new RequestAbortedError();
reject(err);
if (socket) {
socket.destroy(err);
}
};
if (signal) {
if (signal.aborted) {
reject(new RequestAbortedError());
return;
}
signal.addEventListener('abort', onAbortSignal);
}
const port = +url.port || 443;
const onError = (err) => {
// error occured while connecting
if (signal) {
signal.removeEventListener('abort', onAbortSignal);
}
if (!(err instanceof RequestAbortedError)) {
debug(`connecting to ${url.hostname}:${port} failed with: ${err.message}`);
reject(err);
}
};
socket = tls.connect(port, url.hostname, options);
socket.once('secureConnect', () => {
if (signal) {
signal.removeEventListener('abort', onAbortSignal);
}
socket.off('error', onError);
socketIdCounter += 1;
socket.id = socketIdCounter;
debug(`established TLS connection: #${socket.id} (${socket.servername})`);
resolve(socket);
});
socket.once('error', onError);
});
const connect = async (url, options) => {
// use mutex to avoid concurrent socket creation to same origin
let socket = await connectionLock.acquire(url.origin);
try {
if (!socket) {
socket = await connectTLS(url, options);
}
return socket;
} finally {
connectionLock.release(url.origin, socket);
}
};
const determineProtocol = async (ctx, url, signal) => {
// url.origin is null if url.protocol is neither 'http:' nor 'https:' ...
const origin = `${url.protocol}//${url.host}`;
switch (url.protocol) {
case 'http:':
// for simplicity, we assume unencrypted HTTP to be HTTP/1.1
// (although, theoretically, it could also be plain-text HTTP/2 (h2c))
return { protocol: ALPN_HTTP1_1 };
case 'http2:':
// HTTP/2 over TCP (h2c)
return { protocol: ALPN_HTTP2C };
case 'https:':
// need to negotiate protocol
break;
default:
throw new TypeError(`unsupported protocol: ${url.protocol}`);
}
if (ctx.alpnProtocols.length === 1
&& (ctx.alpnProtocols[0] === ALPN_HTTP1_1 || ctx.alpnProtocols[0] === ALPN_HTTP1_0)) {
// shortcut: forced HTTP/1.X, default to HTTP/1.1 (no need to use ALPN to negotiate protocol)
return { protocol: ALPN_HTTP1_1 };
}
// lookup ALPN cache
let protocol = ctx.alpnCache.get(origin);
if (protocol) {
return { protocol };
}
// negotiate via ALPN
const {
options: {
rejectUnauthorized: _rejectUnauthorized,
h1: h1Opts = {},
h2: h2Opts = {},
},
} = ctx;
const rejectUnauthorized = !((_rejectUnauthorized === false
|| h1Opts.rejectUnauthorized === false
|| h2Opts.rejectUnauthorized === false));
const connectOptions = {
servername: url.hostname, // enable SNI (Server Name Indication) extension
ALPNProtocols: ctx.alpnProtocols,
signal, // optional abort signal
rejectUnauthorized,
};
const socket = await connect(url, connectOptions);
// socket.alpnProtocol contains the negotiated protocol (e.g. 'h2', 'http1.1', 'http1.0')
protocol = socket.alpnProtocol;
/* c8 ignore next 3 */
if (!protocol) {
protocol = ALPN_HTTP1_1; // default fallback
}
ctx.alpnCache.set(origin, protocol);
return { protocol, socket };
};
const sanitizeHeaders = (headers) => {
const result = {};
// make all header names lower case
Object.keys(headers).forEach((name) => {
result[name.toLowerCase()] = headers[name];
});
return result;
};
const request = async (ctx, uri, options) => {
const url = new URL(uri);
const opts = { ...DEFAULT_OPTIONS, ...(options || {}) };
// sanitze method name
if (typeof opts.method === 'string') {
opts.method = opts.method.toUpperCase();
}
// sanitize headers (lowercase names)
opts.headers = sanitizeHeaders(opts.headers || {});
// set Host header if none is provided
if (opts.headers.host === undefined) {
opts.headers.host = url.host;
}
// User-Agent header
if (ctx.userAgent) {
if (opts.headers['user-agent'] === undefined) {
opts.headers['user-agent'] = ctx.userAgent;
}
}
// some header magic
let contentType;
if (opts.body instanceof URLSearchParams) {
contentType = 'application/x-www-form-urlencoded; charset=utf-8';
opts.body = opts.body.toString();
} else if (isFormData(opts.body)) {
// spec-compliant FormData
const fd = new FormDataSerializer(opts.body);
contentType = fd.contentType();
opts.body = fd.stream();
if (opts.headers['transfer-encoding'] === undefined
&& opts.headers['content-length'] === undefined) {
opts.headers['content-length'] = String(fd.length());
}
} else if (typeof opts.body === 'string' || opts.body instanceof String) {
contentType = 'text/plain; charset=utf-8';
} else if (isPlainObject(opts.body)) {
opts.body = JSON.stringify(opts.body);
contentType = 'application/json';
} else if (isAnyArrayBuffer(opts.body)) {
opts.body = Buffer.from(opts.body);
}
if (opts.headers['content-type'] === undefined && contentType !== undefined) {
opts.headers['content-type'] = contentType;
}
// by now all supported custom body types are converted to string, readable or buffer
if (opts.body != null) {
if (!(opts.body instanceof Readable)) {
// non-stream body
if (!(typeof opts.body === 'string' || opts.body instanceof String)
&& !Buffer.isBuffer(opts.body)) {
// neither a string or buffer: coerce to string
opts.body = String(opts.body);
}
// string or buffer body
if (opts.headers['transfer-encoding'] === undefined
&& opts.headers['content-length'] === undefined) {
opts.headers['content-length'] = String(Buffer.isBuffer(opts.body)
? opts.body.length
: Buffer.byteLength(opts.body, 'utf-8'));
}
}
}
if (opts.headers.accept === undefined) {
opts.headers.accept = '*/*';
}
if (opts.body == null && ['POST', 'PUT'].includes(opts.method)) {
opts.headers['content-length'] = '0';
}
if (opts.compress && opts.headers['accept-encoding'] === undefined) {
opts.headers['accept-encoding'] = 'gzip,deflate,br';
}
// extract optional abort signal
const { signal } = opts;
// delegate to protocol-specific request handler
const { protocol, socket = null } = await determineProtocol(ctx, url, signal);
debug(`${url.host} -> ${protocol}`);
switch (protocol) {
case ALPN_HTTP2:
try {
return await h2.request(ctx, url, socket ? { ...opts, socket } : opts);
} catch (err) {
const { code, message } = err;
/* c8 ignore next 2 */
if ((code === 'ERR_HTTP2_ERROR' && message === 'Protocol error')
|| code === 'ERR_HTTP2_STREAM_CANCEL') {
// server potentially downgraded from h2 to h1: clear alpn cache entry
ctx.alpnCache.delete(`${url.protocol}//${url.host}`);
}
throw err;
}
case ALPN_HTTP2C:
// plain-text HTTP/2 (h2c)
// url.protocol = 'http:'; => doesn't work ?!
return h2.request(
ctx,
new URL(`http://${url.host}${url.pathname}${url.hash}${url.search}`),
/* c8 ignore next */
socket ? { ...opts, socket } : opts,
);
/* c8 ignore next */ case ALPN_HTTP1_0:
case ALPN_HTTP1_1:
return h1.request(ctx, url, socket ? { ...opts, socket } : opts);
/* c8 ignore next 4 */
default:
// dead branch: only here to make eslint stop complaining
throw new TypeError(`unsupported protocol: ${protocol}`);
}
};
const resetContext = async (ctx) => {
ctx.alpnCache.clear();
return Promise.all([
h1.resetContext(ctx),
h2.resetContext(ctx),
]);
};
const setupContext = (ctx) => {
const {
options: {
alpnProtocols = ALPN_PROTOCOLS,
alpnCacheTTL = ALPN_CACHE_TTL,
alpnCacheSize = ALPN_CACHE_SIZE,
userAgent = DEFAULT_USER_AGENT,
},
} = ctx;
ctx.alpnProtocols = alpnProtocols;
ctx.alpnCache = new LRUCache({ max: alpnCacheSize, ttl: alpnCacheTTL });
ctx.userAgent = userAgent;
h1.setupContext(ctx);
h2.setupContext(ctx);
};
export {
request,
setupContext,
resetContext,
RequestAbortedError,
ALPN_HTTP2,
ALPN_HTTP2C,
ALPN_HTTP1_1,
ALPN_HTTP1_0,
};