UNPKG

13.5 kBJavaScriptView Raw
1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3const JSONParse = require("parse-json");
4const uri = require("url");
5const util = require("util");
6const deps_1 = require("./deps");
7const pjson = require('../package.json');
8const debug = require('debug')('http');
9const debugHeaders = require('debug')('http:headers');
10function concat(stream) {
11 return new Promise(resolve => {
12 let strings = [];
13 stream.on('data', data => strings.push(data));
14 stream.on('end', () => resolve(strings.join('')));
15 });
16}
17function caseInsensitiveObject() {
18 let lowercaseKey = (k) => (typeof k === 'string' ? k.toLowerCase() : k);
19 return new Proxy({}, {
20 get: (t, k) => {
21 k = lowercaseKey(k);
22 return t[k];
23 },
24 set: (t, k, v) => {
25 k = lowercaseKey(k);
26 t[k] = v;
27 return true;
28 },
29 deleteProperty: (t, k) => {
30 k = lowercaseKey(k);
31 if (k in t)
32 return false;
33 return delete t[k];
34 },
35 has: (t, k) => {
36 k = lowercaseKey(k);
37 return k in t;
38 },
39 });
40}
41function lowercaseHeaders(headers) {
42 let newHeaders = caseInsensitiveObject();
43 for (let k of Object.keys(headers)) {
44 if (!headers[k] && headers[k] !== '')
45 continue;
46 newHeaders[k] = headers[k];
47 }
48 return newHeaders;
49}
50/**
51 * Utility for simple HTTP calls
52 * @class
53 */
54class HTTP {
55 constructor(url, options = {}) {
56 this._redirectRetries = 0;
57 this._errorRetries = 0;
58 const userAgent = (global['http-call'] && global['http-call'].userAgent && global['http-call'].userAgent) ||
59 `${pjson.name}/${pjson.version} node-${process.version}`;
60 this.options = Object.assign({}, this.ctor.defaults, options, { headers: lowercaseHeaders(Object.assign({ 'user-agent': userAgent }, this.ctor.defaults.headers, options.headers)) });
61 if (!url)
62 throw new Error('no url provided');
63 this.url = url;
64 if (this.options.body)
65 this._parseBody(this.options.body);
66 }
67 static create(options = {}) {
68 var _a;
69 const defaults = this.defaults;
70 return _a = class CustomHTTP extends HTTP {
71 },
72 _a.defaults = Object.assign({}, defaults, options),
73 _a;
74 }
75 /**
76 * make an http GET request
77 * @param {string} url - url or path to call
78 * @param {HTTPRequestOptions} options
79 * @returns {Promise}
80 * @example
81 * ```js
82 * const http = require('http-call')
83 * await http.get('https://google.com')
84 * ```
85 */
86 static get(url, options = {}) {
87 return this.request(url, Object.assign({}, options, { method: 'GET' }));
88 }
89 /**
90 * make an http POST request
91 * @param {string} url - url or path to call
92 * @param {HTTPRequestOptions} options
93 * @returns {Promise}
94 * @example
95 * ```js
96 * const http = require('http-call')
97 * await http.post('https://google.com')
98 * ```
99 */
100 static post(url, options = {}) {
101 return this.request(url, Object.assign({}, options, { method: 'POST' }));
102 }
103 /**
104 * make an http PUT request
105 * @param {string} url - url or path to call
106 * @param {HTTPRequestOptions} options
107 * @returns {Promise}
108 * @example
109 * ```js
110 * const http = require('http-call')
111 * await http.put('https://google.com')
112 * ```
113 */
114 static put(url, options = {}) {
115 return this.request(url, Object.assign({}, options, { method: 'PUT' }));
116 }
117 /**
118 * make an http PATCH request
119 * @param {string} url - url or path to call
120 * @param {HTTPRequestOptions} options
121 * @returns {Promise}
122 * @example
123 * ```js
124 * const http = require('http-call')
125 * await http.patch('https://google.com')
126 * ```
127 */
128 static async patch(url, options = {}) {
129 return this.request(url, Object.assign({}, options, { method: 'PATCH' }));
130 }
131 /**
132 * make an http DELETE request
133 * @param {string} url - url or path to call
134 * @param {HTTPRequestOptions} options
135 * @returns {Promise}
136 * @example
137 * ```js
138 * const http = require('http-call')
139 * await http.delete('https://google.com')
140 * ```
141 */
142 static async delete(url, options = {}) {
143 return this.request(url, Object.assign({}, options, { method: 'DELETE' }));
144 }
145 /**
146 * make a streaming request
147 * @param {string} url - url or path to call
148 * @param {HTTPRequestOptions} options
149 * @returns {Promise}
150 * @example
151 * ```js
152 * const http = require('http-call')
153 * let {response} = await http.get('https://google.com')
154 * response.on('data', console.log)
155 * ```
156 */
157 static stream(url, options = {}) {
158 return this.request(url, Object.assign({}, options, { raw: true }));
159 }
160 static async request(url, options = {}) {
161 let http = new this(url, options);
162 await http._request();
163 return http;
164 }
165 get method() {
166 return this.options.method || 'GET';
167 }
168 get statusCode() {
169 if (!this.response)
170 return 0;
171 return this.response.statusCode || 0;
172 }
173 get secure() {
174 return this.options.protocol === 'https:';
175 }
176 get url() {
177 return `${this.options.protocol}//${this.options.host}${this.options.path}`;
178 }
179 set url(input) {
180 let u = uri.parse(input);
181 this.options.protocol = u.protocol || this.options.protocol;
182 this.options.host = u.hostname || this.ctor.defaults.host || 'localhost';
183 this.options.path = u.path || '/';
184 this.options.agent = this.options.agent || deps_1.deps.proxy.agent(this.secure, this.options.host);
185 this.options.port = u.port || this.options.port || (this.secure ? 443 : 80);
186 }
187 get headers() {
188 if (!this.response)
189 return {};
190 return this.response.headers;
191 }
192 get partial() {
193 if (this.method !== 'GET' || this.options.partial)
194 return true;
195 return !(this.headers['next-range'] && this.body instanceof Array);
196 }
197 get ctor() {
198 return this.constructor;
199 }
200 async _request() {
201 this._debugRequest();
202 try {
203 this.response = await this._performRequest();
204 }
205 catch (err) {
206 debug(err);
207 return this._maybeRetry(err);
208 }
209 if (this._shouldParseResponseBody)
210 await this._parse();
211 this._debugResponse();
212 if (this._responseRedirect)
213 return this._redirect();
214 if (!this._responseOK) {
215 throw new HTTPError(this);
216 }
217 if (!this.partial)
218 await this._getNextRange();
219 }
220 async _redirect() {
221 this._redirectRetries++;
222 if (this._redirectRetries > 10)
223 throw new Error(`Redirect loop at ${this.url}`);
224 if (!this.headers.location)
225 throw new Error(`Redirect from ${this.url} has no location header`);
226 const location = this.headers.location;
227 if (Array.isArray(location)) {
228 this.url = location[0];
229 }
230 else {
231 this.url = location;
232 }
233 await this._request();
234 }
235 async _maybeRetry(err) {
236 this._errorRetries++;
237 const allowed = (err) => {
238 if (this._errorRetries > 5)
239 return false;
240 if (!err || !err.code)
241 return false;
242 if (err.code === 'ENOTFOUND')
243 return true;
244 return require('is-retry-allowed')(err);
245 };
246 if (allowed(err)) {
247 let noise = Math.random() * 100;
248 // tslint:disable-next-line
249 await this._wait((1 << this._errorRetries) * 100 + noise);
250 await this._request();
251 return;
252 }
253 throw err;
254 }
255 get _chalk() {
256 try {
257 return require('chalk');
258 }
259 catch (err) {
260 return;
261 }
262 }
263 _renderStatus(code) {
264 if (code < 200)
265 return code;
266 if (code < 300)
267 return this._chalk.green(code);
268 if (code < 400)
269 return this._chalk.bold.cyan(code);
270 if (code < 500)
271 return this._chalk.bgYellow(code);
272 if (code < 600)
273 return this._chalk.bgRed(code);
274 return code;
275 }
276 _debugRequest() {
277 if (!debug.enabled)
278 return;
279 let output = [`${this._chalk.bold('→')} ${this._chalk.blue.bold(this.options.method)} ${this._chalk.bold(this.url)}`];
280 if (this.options.agent)
281 output.push(` proxy: ${util.inspect(this.options.agent)}`);
282 if (debugHeaders.enabled)
283 output.push(this._renderHeaders(this.options.headers));
284 if (this.options.body)
285 output.push(this.options.body);
286 debug(output.join('\n'));
287 }
288 _debugResponse() {
289 if (!debug.enabled)
290 return;
291 const chalk = require('chalk');
292 let output = [`${this._chalk.white.bold('←')} ${this._chalk.blue.bold(this.method)} ${this._chalk.bold(this.url)} ${this._renderStatus(this.statusCode)}`];
293 if (debugHeaders.enabled)
294 output.push(this._renderHeaders(this.headers));
295 if (this.body)
296 output.push(util.inspect(this.body));
297 debug(output.join('\n'));
298 }
299 _renderHeaders(headers) {
300 headers = Object.assign({}, headers);
301 if (process.env.HTTP_CALL_REDACT !== '0' && headers.authorization)
302 headers.authorization = '[REDACTED]';
303 return Object.entries(headers)
304 .sort(([a], [b]) => {
305 if (a < b)
306 return -1;
307 if (a > b)
308 return 1;
309 return 0;
310 })
311 .map(([k, v]) => ` ${this._chalk.dim(k + ':')} ${this._chalk.cyan(util.inspect(v))}`)
312 .join('\n');
313 }
314 _performRequest() {
315 return new Promise((resolve, reject) => {
316 if (this.secure) {
317 this.request = deps_1.deps.https.request(this.options, resolve);
318 }
319 else {
320 this.request = deps_1.deps.http.request(this.options, resolve);
321 }
322 if (this.options.timeout) {
323 this.request.setTimeout(this.options.timeout, () => {
324 debug(`← ${this.method} ${this.url} TIMED OUT`);
325 this.request.abort();
326 });
327 }
328 this.request.on('error', reject);
329 this.request.on('timeout', reject);
330 if (this.options.body && deps_1.deps.isStream.readable(this.options.body)) {
331 this.options.body.pipe(this.request);
332 }
333 else {
334 this.request.end(this.options.body);
335 }
336 });
337 }
338 async _parse() {
339 this.body = await concat(this.response);
340 let type = this.response.headers['content-type'] ? deps_1.deps.contentType.parse(this.response).type : '';
341 let json = type.startsWith('application/json') || type.endsWith('+json');
342 if (json)
343 this.body = JSONParse(this.body);
344 }
345 _parseBody(body) {
346 if (deps_1.deps.isStream.readable(body)) {
347 this.options.body = body;
348 return;
349 }
350 if (!this.options.headers['content-type']) {
351 this.options.headers['content-type'] = 'application/json';
352 }
353 if (this.options.headers['content-type'] === 'application/json') {
354 this.options.body = JSON.stringify(body);
355 }
356 else {
357 this.options.body = body;
358 }
359 this.options.headers['content-length'] = Buffer.byteLength(this.options.body).toString();
360 }
361 async _getNextRange() {
362 const next = this.headers['next-range'];
363 this.options.headers.range = Array.isArray(next) ? next[0] : next;
364 let prev = this.body;
365 await this._request();
366 this.body = prev.concat(this.body);
367 }
368 get _responseOK() {
369 if (!this.response)
370 return false;
371 return this.statusCode >= 200 && this.statusCode < 300;
372 }
373 get _responseRedirect() {
374 if (!this.response)
375 return false;
376 return [301, 302, 303, 307, 308].includes(this.statusCode);
377 }
378 get _shouldParseResponseBody() {
379 return !this._responseOK || (!this.options.raw && this._responseOK);
380 }
381 _wait(ms) {
382 return new Promise(resolve => setTimeout(resolve, ms));
383 }
384}
385HTTP.defaults = {
386 method: 'GET',
387 host: 'localhost',
388 protocol: 'https:',
389 path: '/',
390 raw: false,
391 partial: false,
392 headers: {},
393};
394exports.HTTP = HTTP;
395exports.default = HTTP;
396class HTTPError extends Error {
397 constructor(http) {
398 super();
399 this.__httpcall = pjson.version;
400 if (typeof http.body === 'string' || typeof http.body.message === 'string')
401 this.message = http.body.message || http.body;
402 else
403 this.message = util.inspect(http.body);
404 this.message = `HTTP Error ${http.statusCode} for ${http.method} ${http.url}\n${this.message}`;
405 this.statusCode = http.statusCode;
406 this.http = http;
407 this.body = http.body;
408 }
409}
410exports.HTTPError = HTTPError;