const assert = require('assert'); const querystring = require('querystring'); const http = require('http'); const https = require('https'); const { once } = require('events'); const { URL } = require('url'); const LRU = require('lru-cache'); const pkg = require('../../package.json'); const { RPError } = require('../errors'); const pick = require('./pick'); const { deep: defaultsDeep } = require('./defaults'); const { HTTP_OPTIONS } = require('./consts'); let DEFAULT_HTTP_OPTIONS; const NQCHAR = /^[\x21\x23-\x5B\x5D-\x7E]+$/; const allowed = [ 'agent', 'ca', 'cert', 'crl', 'headers', 'key', 'lookup', 'passphrase', 'pfx', 'timeout', ]; const setDefaults = (props, options) => { DEFAULT_HTTP_OPTIONS = defaultsDeep( {}, props.length ? pick(options, ...props) : options, DEFAULT_HTTP_OPTIONS, ); }; setDefaults([], { headers: { 'User-Agent': `${pkg.name}/${pkg.version} (${pkg.homepage})`, 'Accept-Encoding': 'identity', }, timeout: 3500, }); function send(req, body, contentType) { if (contentType) { req.removeHeader('content-type'); req.setHeader('content-type', contentType); } if (body) { req.removeHeader('content-length'); req.setHeader('content-length', Buffer.byteLength(body)); req.write(body); } req.end(); } const nonces = new LRU({ max: 100 }); module.exports = async function request(options, { accessToken, mTLS = false, DPoP } = {}) { let url; try { url = new URL(options.url); delete options.url; assert(/^(https?:)$/.test(url.protocol)); } catch (err) { throw new TypeError('only valid absolute URLs can be requested'); } const optsFn = this[HTTP_OPTIONS]; let opts = options; const nonceKey = `${url.origin}${url.pathname}`; if (DPoP && 'dpopProof' in this) { opts.headers = opts.headers || {}; opts.headers.DPoP = await this.dpopProof( { htu: `${url.origin}${url.pathname}`, htm: options.method || 'GET', nonce: nonces.get(nonceKey), }, DPoP, accessToken, ); } let userOptions; if (optsFn) { userOptions = pick( optsFn.call(this, url, defaultsDeep({}, opts, DEFAULT_HTTP_OPTIONS)), ...allowed, ); } opts = defaultsDeep({}, userOptions, opts, DEFAULT_HTTP_OPTIONS); if (mTLS && !opts.pfx && !(opts.key && opts.cert)) { throw new TypeError('mutual-TLS certificate and key not set'); } if (opts.searchParams) { for (const [key, value] of Object.entries(opts.searchParams)) { url.searchParams.delete(key); url.searchParams.set(key, value); } } let responseType; let form; let json; let body; ({ form, responseType, json, body, ...opts } = opts); for (const [key, value] of Object.entries(opts.headers || {})) { if (value === undefined) { delete opts.headers[key]; } } let response; const req = (url.protocol === 'https:' ? https.request : http.request)(url.href, opts); return (async () => { if (json) { send(req, JSON.stringify(json), 'application/json'); } else if (form) { send(req, querystring.stringify(form), 'application/x-www-form-urlencoded'); } else if (body) { send(req, body); } else { send(req); } [response] = await Promise.race([once(req, 'response'), once(req, 'timeout')]); // timeout reached if (!response) { req.destroy(); throw new RPError(`outgoing request timed out after ${opts.timeout}ms`); } const parts = []; for await (const part of response) { parts.push(part); } if (parts.length) { switch (responseType) { case 'json': { Object.defineProperty(response, 'body', { get() { let value = Buffer.concat(parts); try { value = JSON.parse(value); } catch (err) { Object.defineProperty(err, 'response', { value: response }); throw err; } finally { Object.defineProperty(response, 'body', { value, configurable: true }); } return value; }, configurable: true, }); break; } case undefined: case 'buffer': { Object.defineProperty(response, 'body', { get() { const value = Buffer.concat(parts); Object.defineProperty(response, 'body', { value, configurable: true }); return value; }, configurable: true, }); break; } default: throw new TypeError('unsupported responseType request option'); } } return response; })() .catch((err) => { if (response) Object.defineProperty(err, 'response', { value: response }); throw err; }) .finally(() => { const dpopNonce = response && response.headers['dpop-nonce']; if (dpopNonce && NQCHAR.test(dpopNonce)) { nonces.set(nonceKey, dpopNonce); } }); }; module.exports.setDefaults = setDefaults.bind(undefined, allowed);