rekwest 2.3.6 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/helpers.mjs CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  } from 'util';
11
11
  import zlib from 'zlib';
12
12
  import { Cookies } from './cookies.mjs';
13
+ import { TimeoutError } from './errors.mjs';
13
14
  import { File } from './file.mjs';
14
15
  import { FormData } from './formdata.mjs';
15
16
  import {
@@ -31,6 +32,7 @@ const {
31
32
  HTTP2_HEADER_METHOD,
32
33
  HTTP2_HEADER_PATH,
33
34
  HTTP2_HEADER_SCHEME,
35
+ HTTP2_HEADER_STATUS,
34
36
  HTTP2_METHOD_GET,
35
37
  HTTP2_METHOD_HEAD,
36
38
  } = http2.constants;
@@ -42,6 +44,48 @@ const gunzip = promisify(zlib.gunzip);
42
44
  const deflate = promisify(zlib.deflate);
43
45
  const inflate = promisify(zlib.inflate);
44
46
 
47
+ export const admix = (res, headers, options) => {
48
+ const { h2 } = options;
49
+
50
+ if (h2) {
51
+ Reflect.defineProperty(res, 'headers', {
52
+ enumerable: true,
53
+ value: headers,
54
+ });
55
+
56
+ Reflect.defineProperty(res, 'httpVersion', {
57
+ enumerable: true,
58
+ value: `${ h2 + 1 }.0`,
59
+ });
60
+
61
+ Reflect.defineProperty(res, 'statusCode', {
62
+ enumerable: true,
63
+ value: headers[HTTP2_HEADER_STATUS],
64
+ });
65
+ }
66
+
67
+ Reflect.defineProperty(res, 'ok', {
68
+ enumerable: true,
69
+ value: /^2\d{2}$/.test(res.statusCode),
70
+ });
71
+
72
+ Reflect.defineProperty(res, 'redirected', {
73
+ enumerable: true,
74
+ value: !!options.redirected,
75
+ });
76
+ };
77
+
78
+ export const affix = (client, req, options) => {
79
+ req.once('end', () => client?.close());
80
+ req.once('timeout', () => req.destroy(new TimeoutError(`Timed out after ${ options.timeout } ms.`)));
81
+ req.once('trailers', (trailers) => {
82
+ Reflect.defineProperty(req, 'trailers', {
83
+ enumerable: true,
84
+ value: trailers,
85
+ });
86
+ });
87
+ };
88
+
45
89
  export const compress = (buf, encoding, { async = false } = {}) => {
46
90
  encoding &&= encoding.match(/(?<encoding>\bbr\b|\bdeflate\b|\bgzip\b)/i)?.groups.encoding.toLowerCase();
47
91
  const compressor = {
@@ -118,63 +162,7 @@ export const merge = (target = {}, ...rest) => {
118
162
  return target;
119
163
  };
120
164
 
121
- export const preflight = (options) => {
122
- const url = options.url = new URL(options.url);
123
- const { cookies, h2 = false, method = HTTP2_METHOD_GET, headers, redirected } = options;
124
-
125
- if (h2) {
126
- options.endStream = [
127
- HTTP2_METHOD_GET,
128
- HTTP2_METHOD_HEAD,
129
- ].includes(method);
130
- }
131
-
132
- if (cookies !== false) {
133
- let cookie = Cookies.jar.get(url.origin);
134
-
135
- if (cookies === Object(cookies) && !redirected) {
136
- if (cookie) {
137
- new Cookies(cookies).forEach(function (val, key) {
138
- this.set(key, val);
139
- }, cookie);
140
- } else {
141
- cookie = new Cookies(cookies);
142
- Cookies.jar.set(url.origin, cookie);
143
- }
144
- }
145
-
146
- options.headers = {
147
- ...cookie && { [HTTP2_HEADER_COOKIE]: cookie },
148
- ...headers,
149
- };
150
- }
151
-
152
- options.digest ??= true;
153
- options.follow ??= 20;
154
- options.h2 ??= h2;
155
- options.headers = {
156
- [HTTP2_HEADER_ACCEPT]: `${ APPLICATION_JSON }, ${ TEXT_PLAIN }, ${ WILDCARD }`,
157
- [HTTP2_HEADER_ACCEPT_ENCODING]: 'br, deflate, gzip, identity',
158
- ...Object.entries(options.headers ?? {})
159
- .reduce((acc, [key, val]) => (acc[key.toLowerCase()] = val, acc), {}),
160
- ...h2 && {
161
- [HTTP2_HEADER_AUTHORITY]: url.host,
162
- [HTTP2_HEADER_METHOD]: method,
163
- [HTTP2_HEADER_PATH]: `${ url.pathname }${ url.search }`,
164
- [HTTP2_HEADER_SCHEME]: url.protocol.replace(/\p{Punctuation}/gu, ''),
165
- },
166
- };
167
-
168
- options.method ??= method;
169
- options.parse ??= true;
170
- options.redirect ??= 'follow';
171
- options.redirected ??= false;
172
- options.thenable ??= false;
173
-
174
- return options;
175
- };
176
-
177
- export const premix = (res, { digest = false, parse = false } = {}) => {
165
+ export const mixin = (res, { digest = false, parse = false } = {}) => {
178
166
  if (!digest) {
179
167
  Object.defineProperties(res, {
180
168
  arrayBuffer: {
@@ -214,7 +202,7 @@ export const premix = (res, { digest = false, parse = false } = {}) => {
214
202
  enumerable: true,
215
203
  value: async function () {
216
204
  if (this.bodyUsed) {
217
- throw new TypeError('Response stream already read');
205
+ throw new TypeError('Response stream already read.');
218
206
  }
219
207
 
220
208
  let spool = [];
@@ -262,6 +250,76 @@ export const premix = (res, { digest = false, parse = false } = {}) => {
262
250
  });
263
251
  };
264
252
 
253
+ export const preflight = (options) => {
254
+ const url = options.url = new URL(options.url);
255
+ const { cookies, h2 = false, method = HTTP2_METHOD_GET, headers, redirected } = options;
256
+
257
+ if (h2) {
258
+ options.endStream = [
259
+ HTTP2_METHOD_GET,
260
+ HTTP2_METHOD_HEAD,
261
+ ].includes(method);
262
+ }
263
+
264
+ if (cookies !== false) {
265
+ let cookie = Cookies.jar.get(url.origin);
266
+
267
+ if (cookies === Object(cookies) && !redirected) {
268
+ if (cookie) {
269
+ new Cookies(cookies).forEach(function (val, key) {
270
+ this.set(key, val);
271
+ }, cookie);
272
+ } else {
273
+ cookie = new Cookies(cookies);
274
+ Cookies.jar.set(url.origin, cookie);
275
+ }
276
+ }
277
+
278
+ options.headers = {
279
+ ...cookie && { [HTTP2_HEADER_COOKIE]: cookie },
280
+ ...headers,
281
+ };
282
+ }
283
+
284
+ options.digest ??= true;
285
+ options.follow ??= 20;
286
+ options.h2 ??= h2;
287
+ options.headers = {
288
+ [HTTP2_HEADER_ACCEPT]: `${ APPLICATION_JSON }, ${ TEXT_PLAIN }, ${ WILDCARD }`,
289
+ [HTTP2_HEADER_ACCEPT_ENCODING]: 'br, deflate, gzip, identity',
290
+ ...Object.entries(options.headers ?? {})
291
+ .reduce((acc, [key, val]) => (acc[key.toLowerCase()] = val, acc), {}),
292
+ ...h2 && {
293
+ [HTTP2_HEADER_AUTHORITY]: url.host,
294
+ [HTTP2_HEADER_METHOD]: method,
295
+ [HTTP2_HEADER_PATH]: `${ url.pathname }${ url.search }`,
296
+ [HTTP2_HEADER_SCHEME]: url.protocol.replace(/\p{Punctuation}/gu, ''),
297
+ },
298
+ };
299
+
300
+ options.method ??= method;
301
+ options.parse ??= true;
302
+ options.redirect ??= redirects.follow;
303
+
304
+ if (!Object.values(redirects).includes(options.redirect)) {
305
+ options.createConnection?.().destroy();
306
+ throw new TypeError(`Failed to read the 'redirect' property from 'options': The provided value '${
307
+ options.redirect
308
+ }' is not a valid enum value.`);
309
+ }
310
+
311
+ options.redirected ??= false;
312
+ options.thenable ??= false;
313
+
314
+ return options;
315
+ };
316
+
317
+ export const redirects = {
318
+ error: 'error',
319
+ follow: 'follow',
320
+ manual: 'manual',
321
+ };
322
+
265
323
  export async function* tap(value) {
266
324
  if (Reflect.has(value, Symbol.asyncIterator)) {
267
325
  yield* value;
package/src/index.mjs CHANGED
@@ -1,14 +1,18 @@
1
1
  import http from 'http';
2
2
  import http2 from 'http2';
3
3
  import https from 'https';
4
+ import { setTimeout as setTimeoutPromise } from 'timers/promises';
4
5
  import { ackn } from './ackn.mjs';
5
6
  import { Cookies } from './cookies.mjs';
6
7
  import { RequestError } from './errors.mjs';
7
8
  import {
9
+ admix,
10
+ affix,
8
11
  dispatch,
9
12
  merge,
13
+ mixin,
10
14
  preflight,
11
- premix,
15
+ redirects,
12
16
  transform,
13
17
  } from './helpers.mjs';
14
18
  import { APPLICATION_OCTET_STREAM } from './mediatypes.mjs';
@@ -26,28 +30,58 @@ const {
26
30
  HTTP2_HEADER_CONTENT_LENGTH,
27
31
  HTTP2_HEADER_CONTENT_TYPE,
28
32
  HTTP2_HEADER_LOCATION,
33
+ HTTP2_HEADER_RETRY_AFTER,
29
34
  HTTP2_HEADER_SET_COOKIE,
30
- HTTP2_HEADER_STATUS,
31
35
  HTTP2_METHOD_GET,
32
36
  HTTP2_METHOD_HEAD,
33
37
  HTTP_STATUS_BAD_REQUEST,
38
+ HTTP_STATUS_MOVED_PERMANENTLY,
34
39
  HTTP_STATUS_SEE_OTHER,
40
+ HTTP_STATUS_SERVICE_UNAVAILABLE,
41
+ HTTP_STATUS_TOO_MANY_REQUESTS,
35
42
  } = http2.constants;
36
43
 
44
+ const maxRetryAfter = Symbol('maxRetryAfter');
45
+ const maxRetryAfterError = (
46
+ interval,
47
+ options,
48
+ ) => new RequestError(`Maximum '${ HTTP2_HEADER_RETRY_AFTER }' limit exceeded: ${ interval } ms.`, options);
49
+ let defaults = {
50
+ follow: 20,
51
+ get maxRetryAfter() {
52
+ return this[maxRetryAfter] ?? this.timeout;
53
+ },
54
+ set maxRetryAfter(value) {
55
+ this[maxRetryAfter] = value;
56
+ },
57
+ method: HTTP2_METHOD_GET,
58
+ retry: {
59
+ attempts: 0,
60
+ backoffStrategy: 'interval * Math.log(Math.random() * (Math.E * Math.E - Math.E) + Math.E)',
61
+ interval: 1e3,
62
+ retryAfter: true,
63
+ statusCodes: [
64
+ HTTP_STATUS_TOO_MANY_REQUESTS,
65
+ HTTP_STATUS_SERVICE_UNAVAILABLE,
66
+ ],
67
+ },
68
+ timeout: 3e5,
69
+ };
70
+
37
71
  export default async function rekwest(url, options = {}) {
38
72
  url = options.url = new URL(url);
39
73
  if (!options.redirected) {
40
- options = merge(rekwest.defaults, { follow: 20, method: HTTP2_METHOD_GET }, options);
74
+ options = merge(rekwest.defaults, options);
41
75
  }
42
76
 
43
77
  if (options.body && [
44
78
  HTTP2_METHOD_GET,
45
79
  HTTP2_METHOD_HEAD,
46
80
  ].includes(options.method)) {
47
- throw new TypeError(`Request with ${ HTTP2_METHOD_GET }/${ HTTP2_METHOD_HEAD } method cannot have body`);
81
+ throw new TypeError(`Request with ${ HTTP2_METHOD_GET }/${ HTTP2_METHOD_HEAD } method cannot have body.`);
48
82
  }
49
83
 
50
- if (!options.follow) {
84
+ if (options.follow === 0) {
51
85
  throw new RequestError(`Maximum redirect reached at: ${ url.href }`);
52
86
  }
53
87
 
@@ -80,30 +114,22 @@ export default async function rekwest(url, options = {}) {
80
114
  req = request(url, options);
81
115
  }
82
116
 
83
- req.on('response', (res) => {
84
- if (h2) {
85
- const headers = res;
117
+ affix(client, req, options);
118
+ req.once('error', reject);
119
+ req.once('frameError', reject);
120
+ req.once('goaway', reject);
121
+ req.once('response', (res) => {
122
+ let headers;
86
123
 
124
+ if (h2) {
125
+ headers = res;
87
126
  res = req;
88
-
89
- Reflect.defineProperty(res, 'headers', {
90
- enumerable: true,
91
- value: headers,
92
- });
93
-
94
- Reflect.defineProperty(res, 'httpVersion', {
95
- enumerable: true,
96
- value: `${ h2 + 1 }.0`,
97
- });
98
-
99
- Reflect.defineProperty(res, 'statusCode', {
100
- enumerable: true,
101
- value: headers[HTTP2_HEADER_STATUS],
102
- });
103
127
  } else {
104
- res.on('error', reject);
128
+ res.once('error', reject);
105
129
  }
106
130
 
131
+ admix(res, headers, options);
132
+
107
133
  if (cookies !== false && res.headers[HTTP2_HEADER_SET_COOKIE]) {
108
134
  if (Cookies.jar.has(url.origin)) {
109
135
  new Cookies(res.headers[HTTP2_HEADER_SET_COOKIE]).forEach(function (val, key) {
@@ -122,16 +148,16 @@ export default async function rekwest(url, options = {}) {
122
148
  });
123
149
 
124
150
  if (follow && /^3\d{2}$/.test(res.statusCode) && res.headers[HTTP2_HEADER_LOCATION]) {
125
- if (redirect === 'error') {
126
- res.emit('error', new RequestError(`Unexpected redirect, redirect mode is set to '${ redirect }'`));
151
+ if (redirect === redirects.error) {
152
+ res.emit('error', new RequestError(`Unexpected redirect, redirect mode is set to '${ redirect }'.`));
127
153
  }
128
154
 
129
- if (redirect === 'follow') {
155
+ if (redirect === redirects.follow) {
130
156
  options.url = new URL(res.headers[HTTP2_HEADER_LOCATION], url).href;
131
157
 
132
158
  if (res.statusCode !== HTTP_STATUS_SEE_OTHER
133
159
  && body === Object(body) && body.pipe?.constructor === Function) {
134
- res.emit('error', new RequestError(`Unable to ${ redirect } redirect with body as readable stream`));
160
+ res.emit('error', new RequestError(`Unable to ${ redirect } redirect with body as readable stream.`));
135
161
  }
136
162
 
137
163
  options.follow--;
@@ -144,39 +170,27 @@ export default async function rekwest(url, options = {}) {
144
170
 
145
171
  Reflect.set(options, 'redirected', true);
146
172
 
173
+ if (res.statusCode === HTTP_STATUS_MOVED_PERMANENTLY && res.headers[HTTP2_HEADER_RETRY_AFTER]) {
174
+ let interval = res.headers[HTTP2_HEADER_RETRY_AFTER];
175
+
176
+ interval = Number(interval) * 1000 || new Date(interval) - Date.now();
177
+
178
+ if (interval > options.maxRetryAfter) {
179
+ res.emit('error', maxRetryAfterError(interval, { cause: mixin(res, options) }));
180
+ }
181
+
182
+ return setTimeoutPromise(interval).then(() => rekwest(options.url, options).then(resolve, reject));
183
+ }
184
+
147
185
  return rekwest(options.url, options).then(resolve, reject);
148
186
  }
149
187
  }
150
188
 
151
- Reflect.defineProperty(res, 'ok', {
152
- enumerable: true,
153
- value: /^2\d{2}$/.test(res.statusCode),
154
- });
155
-
156
- Reflect.defineProperty(res, 'redirected', {
157
- enumerable: true,
158
- value: options.redirected,
159
- });
160
-
161
189
  if (res.statusCode >= HTTP_STATUS_BAD_REQUEST) {
162
- return reject(premix(res, options));
190
+ return reject(mixin(res, options));
163
191
  }
164
192
 
165
- resolve(premix(res, options));
166
- });
167
-
168
- req.on('end', () => {
169
- client?.close();
170
- });
171
- req.on('error', reject);
172
- req.on('frameError', reject);
173
- req.on('goaway', reject);
174
- req.on('timeout', req.destroy);
175
- req.on('trailers', (trailers) => {
176
- Reflect.defineProperty(req, 'trailers', {
177
- enumerable: true,
178
- value: trailers,
179
- });
193
+ resolve(mixin(res, options));
180
194
  });
181
195
 
182
196
  dispatch(req, { ...options, body });
@@ -191,6 +205,25 @@ export default async function rekwest(url, options = {}) {
191
205
 
192
206
  return res;
193
207
  } catch (ex) {
208
+ if (options.retry?.attempts && options.retry?.statusCodes.includes(ex.statusCode)) {
209
+ let { interval } = options.retry;
210
+
211
+ if (options.retry.retryAfter && ex.headers[HTTP2_HEADER_RETRY_AFTER]) {
212
+ interval = ex.headers[HTTP2_HEADER_RETRY_AFTER];
213
+ interval = Number(interval) * 1000 || new Date(interval) - Date.now();
214
+ if (interval > options.maxRetryAfter) {
215
+ throw maxRetryAfterError(interval, { cause: ex });
216
+ }
217
+ } else {
218
+ interval = new Function('interval', `return Math.ceil(${ options.retry.backoffStrategy });`)(interval);
219
+ }
220
+
221
+ options.retry.attempts--;
222
+ options.retry.interval = interval;
223
+
224
+ return setTimeoutPromise(interval).then(() => rekwest(url, options));
225
+ }
226
+
194
227
  if (digest && !redirected && ex.body) {
195
228
  ex.body = await ex.body();
196
229
  }
@@ -211,28 +244,38 @@ Reflect.defineProperty(rekwest, 'stream', {
211
244
  ...merge(rekwest.defaults, {
212
245
  headers: { [HTTP2_HEADER_CONTENT_TYPE]: APPLICATION_OCTET_STREAM },
213
246
  }, options),
247
+ redirect: redirects.manual,
214
248
  });
215
249
 
216
- if (options.h2) {
217
- const client = http2.connect(url.origin, options);
218
- const req = client.request(options.headers, options);
219
-
220
- req.on('end', () => {
221
- client.close();
222
- });
250
+ const { h2, url: { protocol } } = options;
251
+ const { request } = (protocol === 'http:' ? http : https);
252
+ let client, req;
223
253
 
224
- return req;
254
+ if (h2) {
255
+ client = http2.connect(url.origin, options);
256
+ req = client.request(options.headers, options);
257
+ } else {
258
+ req = request(options.url, options);
225
259
  }
226
260
 
227
- const { url: { protocol } } = options;
228
- const { request } = (protocol === 'http:' ? http : https);
261
+ affix(client, req, options);
262
+ req.once('response', (res) => {
263
+ let headers;
264
+
265
+ if (h2) {
266
+ headers = res;
267
+ res = req;
268
+ }
269
+
270
+ admix(res, headers, options);
271
+ });
229
272
 
230
- return request(options.url, options);
273
+ return req;
231
274
  },
232
275
  });
233
276
 
234
277
  Reflect.defineProperty(rekwest, 'defaults', {
235
278
  enumerable: true,
236
- value: Object.create(null),
237
- writable: true,
279
+ get() { return defaults; },
280
+ set(value) { defaults = merge(defaults, value); },
238
281
  });