@superhero/http-request 4.0.0

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.
Files changed (4) hide show
  1. package/README.md +351 -0
  2. package/index.js +704 -0
  3. package/index.test.js +555 -0
  4. package/package.json +27 -0
package/index.js ADDED
@@ -0,0 +1,704 @@
1
+ import http from 'node:http'
2
+ import https from 'node:https'
3
+ import http2 from 'node:http2'
4
+ import querystring from 'node:querystring'
5
+ import { URL } from 'node:url'
6
+ import { setTimeout as wait } from 'node:timers/promises'
7
+
8
+ /**
9
+ * @typedef {Object} RequestResponse
10
+ *
11
+ * @property {Number} status - The HTTP status code of the response.
12
+ * @property {Object.<String, String>} headers - The headers of the response.
13
+ * @property {Object|String} body - The response body. If the content type is JSON, the body is parsed.
14
+ * If a downstream stream is provided, the body is piped to the downstream
15
+ * and this body field is omitted.
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} RequestOptions
20
+ *
21
+ * @property {String} url - The URL to make the request to.
22
+ * @property {String} base - The base path to resolve the URL against.
23
+ * @property {Object.<String, String>} [headers] - The request headers.
24
+ * @property {Object|String} [body] - The request body.
25
+ * @property {Object|String} [data] - Alias for body.
26
+ * @property {Number} [timeout] - The request timeout in milliseconds.
27
+ * @property {Number} [retry] - The number of times to retry the request.
28
+ * @property {Number} [retryDelay] - The delay between retries in milliseconds.
29
+ * @property {Number[]} [retryOnStatus] - The HTTP status codes to retry on.
30
+ * @property {Boolean} [retryOnClientTimeout] - Whether to retry on client timeout.
31
+ * @property {Boolean} [retryOnDownstreamError] - Whether to retry on downstream error.
32
+ * @property {Boolean} [retryOnInvalidResponseBodyFormat] - Whether to retry on invalid response body format.
33
+ * @property {Boolean} [retryOnErrorResponseStatus] - Whether to retry on invalid response status.
34
+ * @property {Boolean} [doNotThrowOnErrorStatus] - Set to true to avoid throwing on error status.
35
+ * @property {Boolean} [doNotThrowOnRedirectStatus] - Set to true to avoid throwing on redirect status.
36
+ * @property {Stream.Readable>} [upstream] - An optional upstream stream to make it possible to pipe body to the upstream directly.
37
+ * @property {Stream.Writable} [downstream] - An optional downstream stream to make it possible to pipe body from the downstream to.
38
+ * @property {String} [method] - The HTTP method to use.
39
+ *
40
+ * @see {@link https://nodejs.org/api/http.html#httprequestoptions-callback}
41
+ * @see {@link https://nodejs.org/api/https.html#httpsrequestoptions-callback}
42
+ * @see {@link https://nodejs.org/api/net.html#socketconnectoptions-connectlistener}
43
+ */
44
+
45
+ /**
46
+ * A class for making HTTP/S 1.1 or 2.0 requests.
47
+ * @memberof @superhero/http-request
48
+ */
49
+ export default class Request
50
+ {
51
+ #config
52
+
53
+ /**
54
+ * @param {RequestOptions} config - The default/fallback request options/configurations.
55
+ */
56
+ constructor(config)
57
+ {
58
+ this.#config = 'string' === typeof config
59
+ ? { base:config }
60
+ : config || {}
61
+
62
+ const { base, url } = this.#config
63
+ this.#config.base = base ?? url
64
+ delete this.#config.url
65
+ }
66
+
67
+ /**
68
+ * Connects to a HTTP/2 server.
69
+ *
70
+ * @param {String} authority the URL
71
+ * @param {Object} [options]
72
+ *
73
+ * @throws {Error} E_HTTP_REQUEST_CONNECT_INVALID_ARGUMENT
74
+ *
75
+ * @see {@link https://nodejs.org/api/http2.html#http2connectauthority-options-listener}
76
+ */
77
+ connect(authority, ...args)
78
+ {
79
+ return new Promise(async (accept, reject) =>
80
+ {
81
+ await this.close()
82
+
83
+ const url = new URL(authority || this.#config.base)
84
+ authority = url.protocol + '//' + url.host
85
+
86
+ this.http2Session = http2.connect(authority, ...args.slice(0, 1), () =>
87
+ {
88
+ this.#config.base = authority
89
+ this.#config.url = url.pathname + url.search
90
+ this.http2Session.off('error', reject)
91
+ accept()
92
+ })
93
+
94
+ this.http2Session.once('error', reject)
95
+ this.http2Session.once('close', () =>
96
+ {
97
+ this.http2Session.removeAllListeners()
98
+ delete this.http2Session
99
+ })
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Closes the HTTP/2 client.
105
+ * @param {number} error
106
+ * @see {@link https://nodejs.org/api/http2.html#http2sessionclosecallback}
107
+ */
108
+ close()
109
+ {
110
+ return new Promise((accept, reject) =>
111
+ {
112
+ if(this.http2Session
113
+ && this.http2Session.closed === false)
114
+ {
115
+ this.http2Session.close((error) =>
116
+ {
117
+ error
118
+ ? reject(error)
119
+ : accept()
120
+ })
121
+ }
122
+ else
123
+ {
124
+ accept()
125
+ }
126
+ })
127
+ }
128
+
129
+ /**
130
+ * GET – Read a resource or a collection of resources.
131
+ * Used to retrieve data without modifying it.
132
+ * Example: fetching user details.
133
+ *
134
+ * @param {RequestOptions} options
135
+ * @returns {RequestResponse}
136
+ */
137
+ get(options)
138
+ {
139
+ return this.#fetch('GET', options)
140
+ }
141
+
142
+ /**
143
+ * POST – Create a new resource.
144
+ * Used to submit data to create a new resource on the server.
145
+ * Example: creating a new user.
146
+ *
147
+ * @param {RequestOptions} options
148
+ * @returns {RequestResponse}
149
+ */
150
+ post(options)
151
+ {
152
+ return this.#fetch('POST', options)
153
+ }
154
+
155
+ /**
156
+ * PUT – Replace or update a resource completely.
157
+ * Used for updating an existing resource by replacing it entirely.
158
+ * Example: replacing a user’s details.
159
+ *
160
+ * @param {RequestOptions} options
161
+ * @returns {RequestResponse}
162
+ */
163
+ put(options)
164
+ {
165
+ return this.#fetch('PUT', options)
166
+ }
167
+
168
+ /**
169
+ * PATCH – Partial update of a resource.
170
+ * Used to apply partial modifications, often updating only specific
171
+ * fields. Example: updating just the email in a user profile.
172
+ *
173
+ * @param {RequestOptions} options
174
+ * @returns {RequestResponse}
175
+ */
176
+ patch(options)
177
+ {
178
+ return this.#fetch('PATCH', options)
179
+ }
180
+
181
+ /**
182
+ * DELETE – Remove a resource.
183
+ * Used to delete an existing resource.
184
+ *
185
+ * @param {RequestOptions} options
186
+ * @returns {RequestResponse}
187
+ */
188
+ delete(options)
189
+ {
190
+ return this.#fetch('DELETE', options)
191
+ }
192
+
193
+ /**
194
+ * HEAD – Get metadata (headers) of a resource without getting the body.
195
+ * Similar to GET but retrieves only headers, often used for checking if
196
+ * a resource exists without fetching its body.
197
+ *
198
+ * @param {RequestOptions} options
199
+ * @returns {RequestResponse}
200
+ */
201
+ head(options)
202
+ {
203
+ return this.#fetch('HEAD', options)
204
+ }
205
+
206
+ /**
207
+ * OPTIONS – Get information about a resource’s communication options.
208
+ * Used to find out which methods are supported for a specific resource,
209
+ * helping clients understand server capabilities.
210
+ *
211
+ * @param {RequestOptions} options
212
+ * @returns {RequestResponse}
213
+ */
214
+ options(options)
215
+ {
216
+ return this.#fetch('OPTIONS', options)
217
+ }
218
+
219
+ /**
220
+ * TRACE – Get diagnostic information from the server.
221
+ * Used to retrieve diagnostic information from the server, often used
222
+ * for testing or debugging purposes.
223
+ * Often used to see the path a request takes to reach the server.
224
+ * Rarely used in REST, primarily for network diagnostics.
225
+ *
226
+ * @param {RequestOptions} options
227
+ * @returns {RequestResponse}
228
+ */
229
+ trace(options)
230
+ {
231
+ return this.#fetch('TRACE', options)
232
+ }
233
+
234
+ /**
235
+ * Generic fetch method.
236
+ *
237
+ * @param {string} method
238
+ * @param {RequestOptions} options
239
+ * @returns {RequestResponse}
240
+ */
241
+ #fetch(method, options)
242
+ {
243
+ if(typeof options === 'string')
244
+ {
245
+ options = { url:options }
246
+ }
247
+
248
+ if(this.#config.url && options.url)
249
+ {
250
+ options.url = options.url[0] === '/'
251
+ ? options.url
252
+ : this.#config.url + options.url
253
+ }
254
+
255
+ options = Object.assign(
256
+ {
257
+ method,
258
+ headers : {},
259
+ retry : 3,
260
+ retryDelay : 200,
261
+ url : '',
262
+ timeout : 30e3,
263
+ retryOnStatus : []
264
+ }, this.#config, options)
265
+
266
+ return options.retry
267
+ ? this.#resolveRetryLoop(options)
268
+ : this.#resolve(options)
269
+ }
270
+
271
+ /**
272
+ * Resolves the request.
273
+ *
274
+ * @param {String} method
275
+ * @param {RequestOptions} options
276
+ *
277
+ * @returns {RequestResponse}
278
+ *
279
+ * @throws {Error} E_HTTP_REQUEST_CLIENT_TIMEOUT
280
+ * @throws {Error} E_HTTP_REQUEST_CLIENT_ERROR
281
+ * @throws {Error} E_HTTP_REQUEST_DOWNSTREAM_ERROR
282
+ * @throws {Error} E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT
283
+ */
284
+ #resolve(options)
285
+ {
286
+ return new Promise((accept, reject) =>
287
+ {
288
+ const
289
+ method = options.method,
290
+ headers = this.#normalizeHeaders(options.headers),
291
+ body = this.#normalizeBody(options.body ?? options.data, headers['content-type']),
292
+ delimiter = this.#createBodyHeaderDelimiter(body, !!options.upstream || !!this.http2Session),
293
+ url = this.#normalizeUrl(options.url, options.base)
294
+
295
+ Object.assign(headers, delimiter)
296
+
297
+ const upstream = this.http2Session
298
+ ? this.#resolveHttp2Client(options, method, headers, url, accept, reject)
299
+ : this.#resolveHttp1Client(options, method, headers, url, accept, reject)
300
+
301
+ options.upstream
302
+ ? options.upstream.pipe(upstream)
303
+ : upstream.writable && upstream.end(body)
304
+ })
305
+ }
306
+
307
+ #resolveHttp2Client(options, method, headers, url, accept, reject)
308
+ {
309
+ delete headers['transfer-encoding']
310
+
311
+ const { pathname, search } = new URL(url)
312
+ const upstream = this.http2Session.request(
313
+ {
314
+ [http2.constants.HTTP2_HEADER_METHOD] : method,
315
+ [http2.constants.HTTP2_HEADER_PATH] : pathname + search,
316
+ ...headers
317
+ }, options)
318
+
319
+ upstream.on('close', this.#connectionClosed .bind(this, upstream, reject))
320
+ upstream.on('error', this.#resolveOnClientError .bind(this, method, url, reject))
321
+
322
+ if(options.timeout)
323
+ {
324
+ upstream.setTimeout(options.timeout, this.#resolveOnClientTimeout.bind(this, upstream, options.timeout, method, url, reject))
325
+ }
326
+
327
+ upstream.on('response', (headers) =>
328
+ {
329
+ upstream.headers = headers
330
+ upstream.statusCode = headers[http2.constants.HTTP2_HEADER_STATUS]
331
+
332
+ this.#resolveOnResponse(options, method, url, accept, reject, upstream)
333
+ })
334
+
335
+ return upstream
336
+ }
337
+
338
+ #resolveHttp1Client(options, method, headers, url, accept, reject)
339
+ {
340
+ const
341
+ request = url.protocol === 'https:' ? https.request : http.request,
342
+ config = Object.assign({}, options, { headers }),
343
+ upstream = request(url, config)
344
+
345
+ upstream.on('close', this.#connectionClosed .bind(this, upstream, reject))
346
+ upstream.on('error', this.#resolveOnClientError .bind(this, method, url, reject))
347
+ upstream.on('timeout', this.#resolveOnClientTimeout.bind(this, upstream, options.timeout, method, url, reject))
348
+ upstream.on('response', this.#resolveOnResponse .bind(this, options, method, url, accept, reject))
349
+
350
+ return upstream
351
+ }
352
+
353
+ #connectionClosed(upstream, reject)
354
+ {
355
+ upstream.removeAllListeners()
356
+ const error = new Error('The connection was closed unexpectedly')
357
+ error.code = 'E_HTTP_REQUEST_CLIENT_ERROR'
358
+ setImmediate(() => reject(error)) // this error is a fallback if the promise is not already resolved
359
+ }
360
+
361
+ /**
362
+ * Resolves the response.
363
+ *
364
+ * @param {RequestOptions} options
365
+ * @param {String} method
366
+ * @param {String} url
367
+ * @param {Function} accept Promise accept
368
+ * @param {Function} reject Promise reject
369
+ * @param {http.IncomingMessage} readable
370
+ *
371
+ * @returns {Void}
372
+ */
373
+ #resolveOnResponse(options, method, url, accept, reject, readable)
374
+ {
375
+ const response =
376
+ {
377
+ status : readable.statusCode,
378
+ headers : readable.headers
379
+ }
380
+
381
+ if((response.status >= 400 && false === !!options.doNotThrowOnErrorStatus)
382
+ || (response.status >= 300 && false === !!options.doNotThrowOnRedirectStatus && response.status < 400))
383
+ {
384
+ const error = new Error(`Invalid HTTP status ${response.status} ${method} -> ${url}`)
385
+ error.code = 'E_HTTP_REQUEST_INVALID_RESPONSE_STATUS'
386
+ error.response = response
387
+
388
+ new Promise(this.#bufferResponseBody.bind(this, readable, response, method, url))
389
+ .catch((reason) => error.cause = reason)
390
+ .finally(() => reject(error))
391
+ }
392
+ else if(options.downstream)
393
+ {
394
+ readable.pipe(options.downstream)
395
+ readable.resume()
396
+ accept(response)
397
+ }
398
+ else
399
+ {
400
+ this.#bufferResponseBody(readable, response, method, url, accept, reject)
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Resolves the request in a retry loop.
406
+ *
407
+ * @param {RequestOptions} options
408
+ *
409
+ * @returns {RequestResponse}
410
+ *
411
+ * @throws {Error} E_HTTP_REQUEST_RETRY
412
+ */
413
+ async #resolveRetryLoop(options)
414
+ {
415
+ const errorTrace = []
416
+
417
+ let retry = Math.abs(Math.floor(options.retry))
418
+
419
+ while(retry--)
420
+ {
421
+ try
422
+ {
423
+ const response = await this.#resolve(options)
424
+
425
+ if(response.status >= 400
426
+ && retry > 0
427
+ &&(options.retryOnStatus.includes(response.status)
428
+ || options.retryOnErrorResponseStatus))
429
+ {
430
+ continue
431
+ }
432
+ else
433
+ {
434
+ return response
435
+ }
436
+ }
437
+ catch(error)
438
+ {
439
+ if(retry === 0)
440
+ {
441
+ throw error
442
+ }
443
+ else
444
+ {
445
+ this.#resolveRetryLoopError(options, errorTrace, error)
446
+ await wait(options.retryDelay)
447
+ }
448
+ }
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Resolves the error in the retry loop.
454
+ *
455
+ * @param {RequestOptions} options
456
+ * @param {Error[]} errorTrace
457
+ * @param {Error} error
458
+ *
459
+ * @returns {Void}
460
+ *
461
+ * @throws {Error} E_HTTP_REQUEST_CLIENT_ERROR
462
+ * @throws {Error} E_HTTP_REQUEST_CLIENT_TIMEOUT
463
+ * @throws {Error} E_HTTP_REQUEST_DOWNSTREAM_ERROR
464
+ * @throws {Error} E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT
465
+ * @throws {Error} E_HTTP_REQUEST_INVALID_RESPONSE_STATUS
466
+ */
467
+ #resolveRetryLoopError(options, errorTrace, error)
468
+ {
469
+ switch(error.code)
470
+ {
471
+ case 'E_HTTP_REQUEST_CLIENT_ERROR':
472
+ {
473
+ return errorTrace.push(error)
474
+ }
475
+ case 'E_HTTP_REQUEST_CLIENT_TIMEOUT':
476
+ {
477
+ if(options.retryOnClientTimeout)
478
+ {
479
+ return errorTrace.push(error)
480
+ }
481
+ else
482
+ {
483
+ throw error
484
+ }
485
+ }
486
+ case 'E_HTTP_REQUEST_DOWNSTREAM_ERROR':
487
+ {
488
+ if(options.retryOnDownstreamError)
489
+ {
490
+ return errorTrace.push(error)
491
+ }
492
+ else
493
+ {
494
+ throw error
495
+ }
496
+ }
497
+ case 'E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT':
498
+ {
499
+ if(options.retryOnInvalidResponseBodyFormat)
500
+ {
501
+ return errorTrace.push(error)
502
+ }
503
+ else
504
+ {
505
+ throw error
506
+ }
507
+ }
508
+ case 'E_HTTP_REQUEST_INVALID_RESPONSE_STATUS':
509
+ {
510
+ if(options.retryOnErrorResponseStatus)
511
+ {
512
+ return errorTrace.push(error)
513
+ }
514
+ else if(options.retryOnStatus.includes(error.response.status))
515
+ {
516
+ return errorTrace.push(error)
517
+ }
518
+ else
519
+ {
520
+ throw error
521
+ }
522
+ }
523
+ default:
524
+ {
525
+ throw error
526
+ }
527
+ }
528
+ }
529
+
530
+ #createBodyHeaderDelimiter(body, isStreamed)
531
+ {
532
+ return isStreamed
533
+ ? { 'transfer-encoding': 'chunked' }
534
+ : { 'content-length' : body.length }
535
+ }
536
+
537
+ /**
538
+ * Normalizes the headers to lowercase keys.
539
+ * @param {Object} headers
540
+ * @returns {Object} Normalized headers
541
+ */
542
+ #normalizeHeaders(headers)
543
+ {
544
+ const
545
+ headerKeys = Object.keys(headers),
546
+ normalized = {}
547
+
548
+ headerKeys.forEach((key) => normalized[key.toLowerCase()] = headers[key])
549
+
550
+ return normalized
551
+ }
552
+
553
+ /**
554
+ * Normalizes the body to a string.
555
+ *
556
+ * @param {Object|String} body
557
+ * @param {String} [contentType]
558
+ *
559
+ * @returns {String} Normalized body
560
+ */
561
+ #normalizeBody(body, contentType)
562
+ {
563
+ if('string' === typeof body)
564
+ {
565
+ return body
566
+ }
567
+ else if(contentType?.startsWith('application/json'))
568
+ {
569
+ return JSON.stringify(body)
570
+ }
571
+ else
572
+ {
573
+ return querystring.stringify(body)
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Normalizes the URL.
579
+ *
580
+ * @param {String} url
581
+ * @param {String} base
582
+ *
583
+ * @returns {String} Normalized URL
584
+ */
585
+ #normalizeUrl(url, base)
586
+ {
587
+ return url
588
+ ? new URL(url, base).toString()
589
+ : new URL(base).toString()
590
+ }
591
+
592
+ /**
593
+ * Resolves the client error.
594
+ *
595
+ * @param {String} method
596
+ * @param {String} url
597
+ * @param {Function} reject Promise reject
598
+ * @param {Error} reason
599
+ *
600
+ * @returns {Void}
601
+ *
602
+ * @throws {Error} E_HTTP_REQUEST_CLIENT_ERROR
603
+ */
604
+ #resolveOnClientError(method, url, reject, reason)
605
+ {
606
+ if(reason.code === 'E_HTTP_REQUEST_CLIENT_TIMEOUT')
607
+ {
608
+ return reject(reason)
609
+ }
610
+ else
611
+ {
612
+ const error = new Error(`Client error ${method} → ${url}`)
613
+ error.code = 'E_HTTP_REQUEST_CLIENT_ERROR'
614
+ error.cause = reason
615
+ reject(error)
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Resolves the client timeout.
621
+ *
622
+ * @param {http.ClientRequest} upstream
623
+ * @param {Number} timeout
624
+ * @param {String} method
625
+ * @param {String} url
626
+ * @param {Function} reject Promise reject
627
+ *
628
+ * @returns {Void}
629
+ *
630
+ * @throws {Error} E_HTTP_REQUEST_CLIENT_TIMEOUT
631
+ */
632
+ #resolveOnClientTimeout(upstream, timeout, method, url, reject)
633
+ {
634
+ const error = new Error(`Client timed out (${(timeout / 1e3).toFixed(1)}s) ${method} → ${url}`)
635
+ error.code = 'E_HTTP_REQUEST_CLIENT_TIMEOUT'
636
+
637
+ upstream.destroy(error)
638
+ }
639
+
640
+ #bufferResponseBody(readable, response, method, url, accept, reject)
641
+ {
642
+ response.body = ''
643
+ readable.on('data', (chunk) => response.body += chunk)
644
+ readable.on('error', this.#onStreamError.bind(this, method, url, response))
645
+ readable.on('end', this.#onStreamEnd .bind(this, method, url, response, accept, reject))
646
+ readable.resume()
647
+ }
648
+
649
+ /**
650
+ * Resolves the downstream error.
651
+ *
652
+ * @param {String} method
653
+ * @param {String} url
654
+ * @param {http.IncomingMessage} response
655
+ * @param {Error} reason
656
+ *
657
+ * @returns {void}
658
+ *
659
+ * @throws {Error} E_HTTP_REQUEST_DOWNSTREAM_ERROR
660
+ */
661
+ #onStreamError(method, url, response, reason)
662
+ {
663
+ const error = new Error(`Downstream error [${reason.code}] ${method} -> ${url}`)
664
+ error.code = 'E_HTTP_REQUEST_DOWNSTREAM_ERROR'
665
+ error.cause = reason
666
+ error.response = response
667
+ reject(error)
668
+ }
669
+
670
+ /**
671
+ * Resolves the response end.
672
+ *
673
+ * @param {String} method
674
+ * @param {String} url
675
+ * @param {http.IncomingMessage} response
676
+ * @param {Function} accept promise accept
677
+ * @param {Function} reject promise reject
678
+ *
679
+ * @returns {void}
680
+ *
681
+ * @throws {Error} E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT
682
+ */
683
+ #onStreamEnd(method, url, response, accept, reject)
684
+ {
685
+ if(response.headers['content-type']?.startsWith('application/json'))
686
+ {
687
+ try
688
+ {
689
+ response.body = JSON.parse(response.body || '{}')
690
+ }
691
+ catch(reason)
692
+ {
693
+ const error = new TypeError(`Invalid JSON format ${method} -> ${url}`)
694
+ error.code = 'E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT'
695
+ error.cause = reason
696
+ error.response = response
697
+ reject(error)
698
+ return
699
+ }
700
+ }
701
+
702
+ accept(response)
703
+ }
704
+ }