UNPKG

@adobe/fetch

Version:

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

341 lines (300 loc) 10.3 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 { 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, };