UNPKG

7.24 kBJavaScriptView Raw
1'use strict';
2const {URL, URLSearchParams} = require('url'); // TODO: Use the `URL` global when targeting Node.js 10
3const urlLib = require('url');
4const is = require('@sindresorhus/is');
5const urlParseLax = require('url-parse-lax');
6const lowercaseKeys = require('lowercase-keys');
7const isRetryOnNetworkErrorAllowed = require('./utils/is-retry-on-network-error-allowed');
8const urlToOptions = require('./utils/url-to-options');
9const isFormData = require('./utils/is-form-data');
10const merge = require('./merge');
11const knownHookEvents = require('./known-hook-events');
12
13const retryAfterStatusCodes = new Set([413, 429, 503]);
14
15// `preNormalize` handles static things (lowercasing headers; normalizing baseUrl, timeout, retry)
16// While `normalize` does `preNormalize` + handles things which need to be reworked when user changes them
17const preNormalize = (options, defaults) => {
18 if (is.nullOrUndefined(options.headers)) {
19 options.headers = {};
20 } else {
21 options.headers = lowercaseKeys(options.headers);
22 }
23
24 if (options.baseUrl && !options.baseUrl.toString().endsWith('/')) {
25 options.baseUrl += '/';
26 }
27
28 if (options.stream) {
29 options.json = false;
30 }
31
32 if (is.nullOrUndefined(options.hooks)) {
33 options.hooks = {};
34 } else if (!is.object(options.hooks)) {
35 throw new TypeError(`Parameter \`hooks\` must be an object, not ${is(options.hooks)}`);
36 }
37
38 for (const event of knownHookEvents) {
39 if (is.nullOrUndefined(options.hooks[event])) {
40 if (defaults) {
41 options.hooks[event] = [...defaults.hooks[event]];
42 } else {
43 options.hooks[event] = [];
44 }
45 }
46 }
47
48 if (is.number(options.timeout)) {
49 options.gotTimeout = {request: options.timeout};
50 } else if (is.object(options.timeout)) {
51 options.gotTimeout = options.timeout;
52 }
53 delete options.timeout;
54
55 const {retry} = options;
56 options.retry = {
57 retries: 0,
58 methods: [],
59 statusCodes: []
60 };
61
62 if (is.nonEmptyObject(defaults) && retry !== false) {
63 options.retry = {...defaults.retry};
64 }
65
66 if (retry !== false) {
67 if (is.number(retry)) {
68 options.retry.retries = retry;
69 } else {
70 options.retry = {...options.retry, ...retry};
71 }
72 }
73
74 if (options.gotTimeout) {
75 options.retry.maxRetryAfter = Math.min(...[options.gotTimeout.request, options.gotTimeout.connection].filter(n => !is.nullOrUndefined(n)));
76 }
77
78 if (is.array(options.retry.methods)) {
79 options.retry.methods = new Set(options.retry.methods.map(method => method.toUpperCase()));
80 }
81
82 if (is.array(options.retry.statusCodes)) {
83 options.retry.statusCodes = new Set(options.retry.statusCodes);
84 }
85
86 return options;
87};
88
89const normalize = (url, options, defaults) => {
90 if (is.plainObject(url)) {
91 options = {...url, ...options};
92 url = options.url || {};
93 delete options.url;
94 }
95
96 if (defaults) {
97 options = merge({}, defaults.options, options ? preNormalize(options, defaults.options) : {});
98 } else {
99 options = merge({}, options ? preNormalize(options) : {});
100 }
101
102 if (!is.string(url) && !is.object(url)) {
103 throw new TypeError(`Parameter \`url\` must be a string or object, not ${is(url)}`);
104 }
105
106 if (is.string(url)) {
107 if (options.baseUrl) {
108 if (url.toString().startsWith('/')) {
109 url = url.toString().slice(1);
110 }
111
112 url = urlToOptions(new URL(url, options.baseUrl));
113 } else {
114 url = url.replace(/^unix:/, 'http://$&');
115
116 url = urlParseLax(url);
117 if (url.auth) {
118 throw new Error('Basic authentication must be done with the `auth` option');
119 }
120 }
121 } else if (is(url) === 'URL') {
122 url = urlToOptions(url);
123 }
124
125 // Override both null/undefined with default protocol
126 options = merge({path: ''}, url, {protocol: url.protocol || 'https:'}, options);
127
128 const {baseUrl} = options;
129 Object.defineProperty(options, 'baseUrl', {
130 set: () => {
131 throw new Error('Failed to set baseUrl. Options are normalized already.');
132 },
133 get: () => baseUrl
134 });
135
136 const {query} = options;
137 if (is.nonEmptyString(query) || is.nonEmptyObject(query) || query instanceof URLSearchParams) {
138 if (!is.string(query)) {
139 options.query = (new URLSearchParams(query)).toString();
140 }
141 options.path = `${options.path.split('?')[0]}?${options.query}`;
142 delete options.query;
143 }
144
145 if (options.hostname === 'unix') {
146 const matches = /(.+?):(.+)/.exec(options.path);
147
148 if (matches) {
149 const [, socketPath, path] = matches;
150 options = {
151 ...options,
152 socketPath,
153 path,
154 host: null
155 };
156 }
157 }
158
159 const {headers} = options;
160 for (const [key, value] of Object.entries(headers)) {
161 if (is.nullOrUndefined(value)) {
162 delete headers[key];
163 }
164 }
165
166 if (options.json && is.undefined(headers.accept)) {
167 headers.accept = 'application/json';
168 }
169
170 if (options.decompress && is.undefined(headers['accept-encoding'])) {
171 headers['accept-encoding'] = 'gzip, deflate';
172 }
173
174 const {body} = options;
175 if (is.nullOrUndefined(body)) {
176 options.method = options.method ? options.method.toUpperCase() : 'GET';
177 } else {
178 const isObject = is.object(body) && !is.buffer(body) && !is.nodeStream(body);
179 if (!is.nodeStream(body) && !is.string(body) && !is.buffer(body) && !(options.form || options.json)) {
180 throw new TypeError('The `body` option must be a stream.Readable, string or Buffer');
181 }
182
183 if (options.json && !(isObject || is.array(body))) {
184 throw new TypeError('The `body` option must be an Object or Array when the `json` option is used');
185 }
186
187 if (options.form && !isObject) {
188 throw new TypeError('The `body` option must be an Object when the `form` option is used');
189 }
190
191 if (isFormData(body)) {
192 // Special case for https://github.com/form-data/form-data
193 headers['content-type'] = headers['content-type'] || `multipart/form-data; boundary=${body.getBoundary()}`;
194 } else if (options.form) {
195 headers['content-type'] = headers['content-type'] || 'application/x-www-form-urlencoded';
196 options.body = (new URLSearchParams(body)).toString();
197 } else if (options.json) {
198 headers['content-type'] = headers['content-type'] || 'application/json';
199 options.body = JSON.stringify(body);
200 }
201
202 options.method = options.method ? options.method.toUpperCase() : 'POST';
203 }
204
205 if (!is.function(options.retry.retries)) {
206 const {retries} = options.retry;
207
208 options.retry.retries = (iteration, error) => {
209 if (iteration > retries) {
210 return 0;
211 }
212
213 if (error !== null) {
214 if (!isRetryOnNetworkErrorAllowed(error) && (!options.retry.methods.has(error.method) || !options.retry.statusCodes.has(error.statusCode))) {
215 return 0;
216 }
217
218 if (Reflect.has(error, 'headers') && Reflect.has(error.headers, 'retry-after') && retryAfterStatusCodes.has(error.statusCode)) {
219 let after = Number(error.headers['retry-after']);
220 if (is.nan(after)) {
221 after = Date.parse(error.headers['retry-after']) - Date.now();
222 } else {
223 after *= 1000;
224 }
225
226 if (after > options.retry.maxRetryAfter) {
227 return 0;
228 }
229
230 return after;
231 }
232
233 if (error.statusCode === 413) {
234 return 0;
235 }
236 }
237
238 const noise = Math.random() * 100;
239 return ((2 ** (iteration - 1)) * 1000) + noise;
240 };
241 }
242
243 return options;
244};
245
246const reNormalize = options => normalize(urlLib.format(options), options);
247
248module.exports = normalize;
249module.exports.preNormalize = preNormalize;
250module.exports.reNormalize = reNormalize;