UNPKG

@heroku/http-call

Version:
418 lines (417 loc) 14.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.HTTPError = exports.HTTP = void 0; const uri = require("node:url"); const node_util_1 = require("node:util"); const deps_1 = require("./deps"); const pjson = require('../package.json'); const debug = require('debug')('http'); const debugHeaders = require('debug')('http:headers'); const chalk = require('chalk'); function concat(stream) { return new Promise(resolve => { const strings = []; stream.on('data', data => strings.push(data)); stream.on('end', () => resolve(strings.join(''))); }); } function caseInsensitiveObject() { const lowercaseKey = (k) => (typeof k === 'string' ? k.toLowerCase() : k); return new Proxy({}, { get: (t, k) => { k = lowercaseKey(k); return t[k]; }, set: (t, k, v) => { k = lowercaseKey(k); t[k] = v; return true; }, deleteProperty: (t, k) => { k = lowercaseKey(k); if (k in t) return false; return delete t[k]; }, has: (t, k) => { k = lowercaseKey(k); return k in t; }, }); } function lowercaseHeaders(headers) { const newHeaders = caseInsensitiveObject(); for (const k of Object.keys(headers)) { if (!headers[k] && headers[k] !== '') continue; newHeaders[k] = headers[k]; } return newHeaders; } /** * Utility for simple HTTP calls * @class */ class HTTP { constructor(url, options = {}) { this._redirectRetries = 0; this._errorRetries = 0; const userAgent = (global.httpCall && global.httpCall.userAgent && global.httpCall.userAgent) || `${pjson.name}/${pjson.version} node-${process.version}`; this.options = Object.assign(Object.assign(Object.assign({}, this.ctor.defaults), options), { headers: lowercaseHeaders(Object.assign(Object.assign({ 'user-agent': userAgent }, this.ctor.defaults.headers), options.headers)) }); if (!url) throw new Error('no url provided'); this.url = url; if (this.options.body) this._parseBody(this.options.body); } static create(options = {}) { var _a; const defaults = this.defaults; return _a = class CustomHTTP extends HTTP { }, _a.defaults = Object.assign(Object.assign({}, defaults), options), _a; } /** * make an http GET request * @param {string} url - url or path to call * @param {HTTPRequestOptions} options * @returns {Promise} * @example * ```js * const http = require('http-call') * await http.get('https://google.com') * ``` */ static get(url, options = {}) { return this.request(url, Object.assign(Object.assign({}, options), { method: 'GET' })); } /** * make an http POST request * @param {string} url - url or path to call * @param {HTTPRequestOptions} options * @returns {Promise} * @example * ```js * const http = require('http-call') * await http.post('https://google.com') * ``` */ static post(url, options = {}) { return this.request(url, Object.assign(Object.assign({}, options), { method: 'POST' })); } /** * make an http PUT request * @param {string} url - url or path to call * @param {HTTPRequestOptions} options * @returns {Promise} * @example * ```js * const http = require('http-call') * await http.put('https://google.com') * ``` */ static put(url, options = {}) { return this.request(url, Object.assign(Object.assign({}, options), { method: 'PUT' })); } /** * make an http PATCH request * @param {string} url - url or path to call * @param {HTTPRequestOptions} options * @returns {Promise} * @example * ```js * const http = require('http-call') * await http.patch('https://google.com') * ``` */ static async patch(url, options = {}) { return this.request(url, Object.assign(Object.assign({}, options), { method: 'PATCH' })); } /** * make an http DELETE request * @param {string} url - url or path to call * @param {HTTPRequestOptions} options * @returns {Promise} * @example * ```js * const http = require('http-call') * await http.delete('https://google.com') * ``` */ static async delete(url, options = {}) { return this.request(url, Object.assign(Object.assign({}, options), { method: 'DELETE' })); } /** * make a streaming request * @param {string} url - url or path to call * @param {HTTPRequestOptions} options * @returns {Promise} * @example * ```js * const http = require('http-call') * let {response} = await http.get('https://google.com') * response.on('data', console.log) * ``` */ static stream(url, options = {}) { return this.request(url, Object.assign(Object.assign({}, options), { raw: true })); } static async request(url, options = {}) { const http = new this(url, options); await http._request(); return http; } get method() { return this.options.method || 'GET'; } get statusCode() { if (!this.response) return 0; return this.response.statusCode || 0; } get secure() { return this.options.protocol === 'https:'; } get url() { return `${this.options.protocol}//${this.options.host}${this.options.path}`; } set url(input) { // eslint-disable-next-line node/no-deprecated-api const u = uri.parse(input); this.options.protocol = u.protocol || this.options.protocol; this.options.host = u.hostname || this.ctor.defaults.host || 'localhost'; this.options.path = u.path || '/'; this.options.agent = this.options.agent || deps_1.deps.proxy.agent(this.secure, this.options.host); this.options.port = u.port || this.options.port || (this.secure ? 443 : 80); } get headers() { if (!this.response) return {}; return this.response.headers; } get partial() { if (this.method !== 'GET' || this.options.partial) return true; return !(this.headers['next-range'] && Array.isArray(this.body)); } get ctor() { return this.constructor; } async _request() { this._debugRequest(); try { this.response = await this._performRequest(); } catch (error) { debug(error); return this._maybeRetry(error); } if (this._shouldParseResponseBody) await this._parse(); this._debugResponse(); if (this._responseRedirect) return this._redirect(); if (!this._responseOK) { throw new HTTPError(this); } if (!this.partial) await this._getNextRange(); } async _redirect() { this._redirectRetries++; if (this._redirectRetries > 10) throw new Error(`Redirect loop at ${this.url}`); if (!this.headers.location) throw new Error(`Redirect from ${this.url} has no location header`); const location = this.headers.location; if (Array.isArray(location)) { this.url = location[0]; } else { this.url = location; } await this._request(); } async _maybeRetry(err) { this._errorRetries++; const allowed = (err) => { if (this._errorRetries > 5) return false; if (!err || !err.code) return false; if (err.code === 'ENOTFOUND') return true; return require('is-retry-allowed')(err); }; if (allowed(err)) { const noise = Math.random() * 100; // tslint:disable-next-line await this._wait(((1 << this._errorRetries) * 100) + noise); await this._request(); return; } throw err; } _renderStatus(code) { if (code < 200) return code; if (code < 300) return chalk.green(code); if (code < 400) return chalk.bold.cyan(code); if (code < 500) return chalk.bgYellow(code); if (code < 600) return chalk.bgRed(code); return code; } _debugRequest() { if (!debug.enabled) return; const output = [`${chalk.bold('→')} ${chalk.blue.bold(this.options.method)} ${chalk.bold(this.url)}`]; if (this.options.agent) output.push(` proxy: ${(0, node_util_1.inspect)(this.options.agent)}`); if (debugHeaders.enabled) output.push(this._renderHeaders(this.options.headers)); if (this.options.body) output.push(this.options.body); debug(output.join('\n')); } _debugResponse() { var _a; if (!debug.enabled) return; const output = [`${chalk.white.bold('←')} ${chalk.blue.bold(this.method)} ${chalk.bold(this.url)} ${this._renderStatus(this.statusCode)}`]; if (debugHeaders.enabled) output.push(this._renderHeaders(this.headers)); if (this.body) { if ((_a = this.options.path) === null || _a === void 0 ? void 0 : _a.endsWith('/sso')) { output.push('[REDACTED]'); } else output.push((0, node_util_1.inspect)(this.body)); } debug(output.join('\n')); } _renderHeaders(headers) { headers = Object.assign({}, headers); if (process.env.HTTP_CALL_REDACT !== '0' && headers.authorization) headers.authorization = '[REDACTED]'; if (process.env.HTTP_CALL_REDACT !== '0' && headers['x-addon-sso']) headers['x-addon-sso'] = '[REDACTED]'; return Object.entries(headers) .sort(([a], [b]) => { if (a < b) return -1; if (a > b) return 1; return 0; }) .map(([k, v]) => ` ${chalk.dim(k + ':')} ${chalk.cyan((0, node_util_1.inspect)(v))}`) .join('\n'); } _performRequest() { return new Promise((resolve, reject) => { if (this.secure) { this.request = deps_1.deps.https.request(this.options, resolve); } else { this.request = deps_1.deps.http.request(this.options, resolve); } if (this.options.timeout) { this.request.setTimeout(this.options.timeout); this.request.on('timeout', () => { debug(`← ${this.method} ${this.url} TIMEOUT`); this.request.destroy(); reject(new Error('Request timed out')); }); } this.request.on('error', error => { debug(`← ${this.method} ${this.url} ERROR`); this.request.destroy(); reject(error); }); if (this.options.body && deps_1.deps.isStream.readable(this.options.body)) { this.options.body.pipe(this.request); } else { this.request.end(this.options.body); } }); } async _parse() { this.body = await concat(this.response); const type = this.response.headers['content-type'] ? deps_1.deps.contentType.parse(this.response).type : ''; const json = type.startsWith('application/json') || type.endsWith('+json'); if (json) this.body = JSON.parse(this.body); } _parseBody(body) { if (deps_1.deps.isStream.readable(body)) { this.options.body = body; return; } if (!this.options.headers['content-type']) { this.options.headers['content-type'] = 'application/json'; } if (this.options.headers['content-type'] === 'application/json') { this.options.body = JSON.stringify(body); } else { this.options.body = body; } this.options.headers['content-length'] = Buffer.byteLength(this.options.body).toString(); } async _getNextRange() { const next = this.headers['next-range']; this.options.headers.range = Array.isArray(next) ? next[0] : next; const prev = this.body; await this._request(); this.body = prev.concat(this.body); } get _responseOK() { if (!this.response) return false; return this.statusCode >= 200 && this.statusCode < 300; } get _responseRedirect() { if (!this.response) return false; return [301, 302, 303, 307, 308].includes(this.statusCode); } get _shouldParseResponseBody() { return !this._responseOK || (!this.options.raw && this._responseOK); } _wait(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } } exports.HTTP = HTTP; HTTP.defaults = { method: 'GET', host: 'localhost', protocol: 'https:', path: '/', raw: false, partial: false, headers: {}, timeout: 60 * 1000, }; exports.default = HTTP; class HTTPError extends Error { constructor(http) { super(); this.__httpcall = pjson.version; if (typeof http.body === 'string' || typeof http.body.message === 'string') this.message = http.body.message || http.body; else this.message = (0, node_util_1.inspect)(http.body); this.message = `HTTP Error ${http.statusCode} for ${http.method} ${http.url}\n${this.message}`; this.statusCode = http.statusCode; this.http = http; this.body = http.body; } } exports.HTTPError = HTTPError;