@nxtedition/nxt-undici 2.0.46 → 2.0.47

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/lib/index.js CHANGED
@@ -2,7 +2,7 @@ import assert from 'node:assert'
2
2
  import createError from 'http-errors'
3
3
  import undici from 'undici'
4
4
  import { findHeader, parseHeaders, AbortError, isStream } from './utils.js'
5
- import { BodyReadable as Readable } from './readable.js'
5
+ import { BodyReadable } from './readable.js'
6
6
 
7
7
  const dispatcherCache = new WeakMap()
8
8
 
@@ -19,14 +19,13 @@ function genReqId() {
19
19
  return `req-${nextReqId.toString(36)}`
20
20
  }
21
21
 
22
- const dispatchers = {
22
+ export const interceptors = {
23
23
  responseError: (await import('./interceptor/response-error.js')).default,
24
24
  requestBodyFactory: (await import('./interceptor/request-body-factory.js')).default,
25
25
  responseContent: (await import('./interceptor/response-content.js')).default,
26
26
  log: (await import('./interceptor/log.js')).default,
27
27
  redirect: (await import('./interceptor/redirect.js')).default,
28
28
  responseRetry: (await import('./interceptor/response-retry.js')).default,
29
- responseRetryBody: (await import('./interceptor/response-retry-body.js')).default,
30
29
  proxy: (await import('./interceptor/proxy.js')).default,
31
30
  cache: (await import('./interceptor/cache.js')).default,
32
31
  requestId: (await import('./interceptor/request-id.js')).default,
@@ -110,16 +109,15 @@ export async function request(url, opts) {
110
109
  let dispatch = dispatcherCache.get(dispatcher)
111
110
  if (dispatch == null) {
112
111
  dispatch = (opts, handler) => dispatcher.dispatch(opts, handler)
113
- dispatch = dispatchers.responseError(dispatch)
114
- dispatch = dispatchers.requestBodyFactory(dispatch)
115
- dispatch = dispatchers.log(dispatch)
116
- dispatch = dispatchers.requestId(dispatch)
117
- dispatch = dispatchers.responseRetry(dispatch)
118
- dispatch = dispatchers.responseRetryBody(dispatch)
119
- dispatch = dispatchers.responseContent(dispatch)
120
- dispatch = dispatchers.cache(dispatch)
121
- dispatch = dispatchers.redirect(dispatch)
122
- dispatch = dispatchers.proxy(dispatch)
112
+ dispatch = interceptors.responseError(dispatch)
113
+ dispatch = interceptors.requestBodyFactory(dispatch)
114
+ dispatch = interceptors.log(dispatch)
115
+ dispatch = interceptors.requestId(dispatch)
116
+ dispatch = interceptors.responseRetry(dispatch)
117
+ dispatch = interceptors.responseContent(dispatch)
118
+ dispatch = interceptors.cache(dispatch)
119
+ dispatch = interceptors.redirect(dispatch)
120
+ dispatch = interceptors.proxy(dispatch)
123
121
  dispatcherCache.set(dispatcher, dispatch)
124
122
  }
125
123
 
@@ -192,7 +190,7 @@ export async function request(url, opts) {
192
190
  const contentLength = findHeader(headers, 'content-length')
193
191
  const contentType = findHeader(headers, 'content-type')
194
192
 
195
- this.body = new Readable(this, {
193
+ this.body = new BodyReadable(this, {
196
194
  resume,
197
195
  abort: this.abort,
198
196
  highWaterMark: this.highWaterMark,
@@ -1,30 +1,35 @@
1
1
  import assert from 'node:assert'
2
2
  import { LRUCache } from 'lru-cache'
3
- import { findHeader, parseCacheControl } from '../utils.js'
3
+ import { parseHeaders, parseCacheControl } from '../utils.js'
4
+ import { DecoratorHandler } from 'undici'
5
+
6
+ class CacheHandler extends DecoratorHandler {
7
+ #handler
8
+ #store
9
+ #key
10
+ #value
4
11
 
5
- class CacheHandler {
6
12
  constructor({ key, handler, store }) {
7
- this.key = key
8
- this.handler = handler
9
- this.store = store
10
- this.value = null
13
+ super(handler)
14
+
15
+ this.#key = key
16
+ this.#handler = handler
17
+ this.#store = store
11
18
  }
12
19
 
13
20
  onConnect(abort) {
14
- return this.handler.onConnect(abort)
15
- }
21
+ this.#value = null
16
22
 
17
- onUpgrade(statusCode, rawHeaders, socket, headers) {
18
- return this.handler.onUpgrade(statusCode, rawHeaders, socket, headers)
23
+ return this.#handler.onConnect(abort)
19
24
  }
20
25
 
21
- onHeaders(statusCode, rawHeaders, resume, statusMessage, headers) {
26
+ onHeaders(statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
22
27
  // NOTE: Only cache 307 respones for now...
23
28
  if (statusCode !== 307) {
24
- return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
29
+ return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
25
30
  }
26
31
 
27
- const cacheControl = parseCacheControl(findHeader(rawHeaders, 'cache-control'))
32
+ const cacheControl = parseCacheControl(headers['cache-control'])
28
33
 
29
34
  if (
30
35
  cacheControl &&
@@ -44,7 +49,7 @@ class CacheHandler {
44
49
  : Number(maxAge)
45
50
 
46
51
  if (ttl > 0) {
47
- this.value = {
52
+ this.#value = {
48
53
  statusCode,
49
54
  statusMessage,
50
55
  rawHeaders,
@@ -56,31 +61,27 @@ class CacheHandler {
56
61
  }
57
62
  }
58
63
 
59
- return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
64
+ return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
60
65
  }
61
66
 
62
67
  onData(chunk) {
63
- if (this.value) {
64
- this.value.size += chunk.bodyLength
65
- if (this.value.size > this.store.maxEntrySize) {
66
- this.value = null
68
+ if (this.#value) {
69
+ this.#value.size += chunk.bodyLength
70
+ if (this.#value.size > this.#store.maxEntrySize) {
71
+ this.#value = null
67
72
  } else {
68
- this.value.body.push(chunk)
73
+ this.#value.body.push(chunk)
69
74
  }
70
75
  }
71
- return this.handler.onData(chunk)
76
+ return this.#handler.onData(chunk)
72
77
  }
73
78
 
74
79
  onComplete(rawTrailers) {
75
- if (this.value) {
76
- this.value.rawTrailers = rawTrailers
77
- this.store.set(this.key, this.value, this.value.ttl)
80
+ if (this.#value) {
81
+ this.#value.rawTrailers = rawTrailers
82
+ this.#store.set(this.#key, this.#value, this.#value.ttl)
78
83
  }
79
- return this.handler.onComplete(rawTrailers)
80
- }
81
-
82
- onError(err) {
83
- return this.handler.onError(err)
84
+ return this.#handler.onComplete(rawTrailers)
84
85
  }
85
86
  }
86
87
 
@@ -1,15 +1,24 @@
1
1
  import { performance } from 'node:perf_hooks'
2
2
  import { parseHeaders } from '../utils.js'
3
+ import { DecoratorHandler } from 'undici'
4
+
5
+ class Handler extends DecoratorHandler {
6
+ #handler
7
+ #opts
8
+ #abort
9
+ #aborted = false
10
+ #logger
11
+ #pos
12
+ #stats
3
13
 
4
- class Handler {
5
14
  constructor(opts, { handler }) {
6
- this.handler = handler
7
- this.opts = opts
8
- this.abort = null
9
- this.aborted = false
10
- this.logger = opts.logger.child({ ureq: { id: opts.id } })
11
- this.pos = 0
12
- this.stats = {
15
+ super(handler)
16
+
17
+ this.#handler = handler
18
+ this.#opts = opts
19
+ this.#logger = opts.logger.child({ ureq: { id: opts.id } })
20
+ this.#stats = {
21
+ created: performance.now(),
13
22
  start: -1,
14
23
  end: -1,
15
24
  headers: -1,
@@ -19,89 +28,97 @@ class Handler {
19
28
  }
20
29
 
21
30
  onConnect(abort) {
22
- this.abort = abort
23
- this.stats.start = performance.now()
24
- this.logger.debug({ ureq: this.opts }, 'upstream request started')
25
-
26
- return this.handler.onConnect((reason) => {
27
- this.aborted = true
28
- this.abort(reason)
31
+ this.#pos = 0
32
+ this.#abort = abort
33
+ this.#stats.start = performance.now()
34
+ this.#stats.end = -1
35
+ this.#stats.headers = -1
36
+ this.#stats.firstBodyReceived = -1
37
+ this.#stats.lastBodyReceived = -1
38
+
39
+ this.#logger.debug({ ureq: this.#opts }, 'upstream request started')
40
+
41
+ return this.#handler.onConnect((reason) => {
42
+ this.#aborted = true
43
+ this.#abort(reason)
29
44
  })
30
45
  }
31
46
 
32
47
  onUpgrade(statusCode, rawHeaders, socket, headers) {
33
- this.logger.debug('upstream request upgraded')
48
+ this.#logger.debug('upstream request upgraded')
34
49
  socket.on('close', () => {
35
- this.logger.debug('upstream request socket closed')
50
+ this.#logger.debug('upstream request socket closed')
36
51
  })
37
52
 
38
- return this.handler.onUpgrade(statusCode, rawHeaders, socket, headers)
53
+ return this.#handler.onUpgrade(statusCode, rawHeaders, socket, headers)
39
54
  }
40
55
 
41
56
  onHeaders(statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
42
- this.stats.headers = performance.now() - this.stats.start
57
+ this.#stats.headers = performance.now() - this.#stats.start
43
58
 
44
- this.logger.debug(
59
+ this.#logger.debug(
45
60
  {
46
61
  ures: { statusCode, headers },
47
- elapsedTime: this.stats.headers,
62
+ elapsedTime: this.#stats.headers,
48
63
  },
49
64
  'upstream request response',
50
65
  )
51
66
 
52
- return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
67
+ return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
53
68
  }
54
69
 
55
70
  onData(chunk) {
56
- if (this.stats.firstBodyReceived === -1) {
57
- this.stats.firstBodyReceived = performance.now() - this.stats.start
71
+ if (this.#stats.firstBodyReceived === -1) {
72
+ this.#stats.firstBodyReceived = performance.now() - this.#stats.start
58
73
  }
59
74
 
60
- this.pos += chunk.length
75
+ this.#pos += chunk.length
61
76
 
62
- return this.handler.onData(chunk)
77
+ return this.#handler.onData(chunk)
63
78
  }
64
79
 
65
80
  onComplete(rawTrailers) {
66
- this.stats.lastBodyReceived = performance.now() - this.stats.start
67
- this.stats.end = this.stats.lastBodyReceived
81
+ this.#stats.lastBodyReceived = performance.now() - this.#stats.start
82
+ this.#stats.end = this.#stats.lastBodyReceived
68
83
 
69
- this.logger.debug(
70
- { bytesRead: this.pos, elapsedTime: this.stats.end, stats: this.stats },
84
+ this.#logger.debug(
85
+ { bytesRead: this.#pos, elapsedTime: this.#stats.end, stats: this.#stats },
71
86
  'upstream request completed',
72
87
  )
73
88
 
74
- return this.handler.onComplete(rawTrailers)
89
+ return this.#handler.onComplete(rawTrailers)
75
90
  }
76
91
 
77
92
  onError(err) {
78
- this.stats.end = performance.now() - this.stats.start
93
+ if (this.#stats) {
94
+ this.#stats.end = performance.now() - this.#stats.start
95
+ }
79
96
 
80
- if (this.aborted) {
81
- this.logger.debug(
97
+ if (this.#aborted) {
98
+ this.#logger.debug(
82
99
  {
83
- ureq: this.opts,
84
- bytesRead: this.pos,
85
- elapsedTime: this.stats.end,
86
- stats: this.stats,
100
+ ureq: this.#opts,
101
+ bytesRead: this.#pos,
102
+ elapsedTime: this.#stats.end,
103
+ stats: this.#stats,
87
104
  err,
88
105
  },
89
106
  'upstream request aborted',
90
107
  )
91
108
  } else {
92
- this.logger.error(
109
+ this.#logger.error(
93
110
  {
94
- ureq: this.opts,
95
- bytesRead: this.pos,
96
- elapsedTime: this.stats.end,
97
- stats: this.stats,
111
+ ureq: this.#opts,
112
+ bytesRead: this.#pos,
113
+ elapsedTime: this.#stats.end,
114
+ stats: this.#stats,
98
115
  err,
99
116
  },
100
117
  'upstream request failed',
101
118
  )
102
119
  }
103
120
 
104
- return this.handler.onError(err)
121
+ return this.#handler.onError(err)
105
122
  }
106
123
  }
107
124
 
@@ -1,25 +1,27 @@
1
1
  import net from 'node:net'
2
2
  import createError from 'http-errors'
3
+ import { DecoratorHandler } from 'undici'
3
4
 
4
- class Handler {
5
- constructor(opts, { handler }) {
6
- this.handler = handler
7
- this.opts = opts
8
- }
5
+ class Handler extends DecoratorHandler {
6
+ #handler
7
+ #opts
8
+
9
+ constructor(proxyOpts, { handler }) {
10
+ super(handler)
9
11
 
10
- onConnect(abort) {
11
- return this.handler.onConnect(abort)
12
+ this.#handler = handler
13
+ this.#opts = proxyOpts
12
14
  }
13
15
 
14
16
  onUpgrade(statusCode, rawHeaders, socket) {
15
- return this.handler.onUpgrade(
17
+ return this.#handler.onUpgrade(
16
18
  statusCode,
17
19
  reduceHeaders(
18
20
  {
19
21
  headers: rawHeaders,
20
- httpVersion: this.opts.proxy.httpVersion ?? this.opts.proxy.req?.httpVersion,
22
+ httpVersion: this.#opts.httpVersion ?? this.#opts.req?.httpVersion,
21
23
  socket: null,
22
- proxyName: this.opts.proxy.name,
24
+ proxyName: this.#opts.name,
23
25
  },
24
26
  (acc, key, val) => {
25
27
  acc.push(key, val)
@@ -32,14 +34,14 @@ class Handler {
32
34
  }
33
35
 
34
36
  onHeaders(statusCode, rawHeaders, resume, statusMessage) {
35
- return this.handler.onHeaders(
37
+ return this.#handler.onHeaders(
36
38
  statusCode,
37
39
  reduceHeaders(
38
40
  {
39
41
  headers: rawHeaders,
40
- httpVersion: this.opts.proxy.httpVersion ?? this.opts.proxy.req?.httpVersion,
42
+ httpVersion: this.#opts.httpVersion ?? this.#opts.req?.httpVersion,
41
43
  socket: null,
42
- proxyName: this.opts.proxy.name,
44
+ proxyName: this.#opts.name,
43
45
  },
44
46
  (acc, key, val) => {
45
47
  acc.push(key, val)
@@ -51,18 +53,6 @@ class Handler {
51
53
  statusMessage,
52
54
  )
53
55
  }
54
-
55
- onData(chunk) {
56
- return this.handler.onData(chunk)
57
- }
58
-
59
- onComplete(rawTrailers) {
60
- return this.handler.onComplete(rawTrailers)
61
- }
62
-
63
- onError(err) {
64
- return this.handler.onError(err)
65
- }
66
56
  }
67
57
 
68
58
  export default (dispatch) => (opts, handler) => {
@@ -84,9 +74,7 @@ export default (dispatch) => (opts, handler) => {
84
74
  {},
85
75
  )
86
76
 
87
- opts = { ...opts, headers }
88
-
89
- return dispatch(opts, new Handler(opts, { handler }))
77
+ return dispatch({ ...opts, headers }, new Handler(opts.proxy, { handler }))
90
78
  }
91
79
 
92
80
  // This expression matches hop-by-hop headers.
@@ -1,10 +1,13 @@
1
1
  import assert from 'node:assert'
2
2
  import { findHeader, isDisturbed, parseURL } from '../utils.js'
3
+ import { DecoratorHandler } from 'undici'
3
4
 
4
5
  const redirectableStatusCodes = [300, 301, 302, 303, 307, 308]
5
6
 
6
- class Handler {
7
+ class Handler extends DecoratorHandler {
7
8
  constructor(opts, { dispatch, handler }) {
9
+ super(handler)
10
+
8
11
  this.dispatch = dispatch
9
12
  this.handler = handler
10
13
  this.opts = opts
@@ -1,63 +1,80 @@
1
1
  import crypto from 'node:crypto'
2
- import { findHeader } from '../utils.js'
2
+ import assert from 'node:assert'
3
+ import { parseHeaders } from '../utils.js'
4
+ import { DecoratorHandler } from 'undici'
5
+
6
+ class Handler extends DecoratorHandler {
7
+ #handler
8
+
9
+ #contentMD5
10
+ #contentLength
11
+ #hasher
12
+ #pos
13
+ #errored
3
14
 
4
- class Handler {
5
15
  constructor(opts, { handler }) {
6
- this.handler = handler
7
- this.opts = opts
8
- this.md5 = null
9
- this.length = null
10
- this.hasher = null
11
- this.pos = 0
16
+ super(handler)
17
+
18
+ this.#handler = handler
12
19
  }
13
20
 
14
21
  onConnect(abort) {
15
- return this.handler.onConnect(abort)
16
- }
22
+ assert(!this.#pos)
17
23
 
18
- onUpgrade(statusCode, rawHeaders, socket, headers) {
19
- return this.handler.onUpgrade(statusCode, rawHeaders, socket, headers)
24
+ this.#contentMD5 = null
25
+ this.#contentLength = null
26
+ this.#hasher = null
27
+ this.#pos = 0
28
+ this.#errored = false
29
+
30
+ this.#handler.onConnect(abort)
20
31
  }
21
32
 
22
- onHeaders(statusCode, rawHeaders, resume, statusMessage, headers) {
23
- this.md5 = headers ? headers['content-md5'] : findHeader(rawHeaders, 'content-md5')
24
- this.length = headers ? headers['content-length'] : findHeader(rawHeaders, 'content-length')
25
- this.hasher = this.md5 != null ? crypto.createHash('md5') : null
33
+ onHeaders(statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
34
+ this.#contentMD5 = headers ? headers['content-md5'] : headers['content-md5']
35
+ this.#contentLength = headers ? headers['content-length'] : headers['content-length']
36
+ this.#hasher = this.#contentMD5 != null ? crypto.createHash('md5') : null
26
37
 
27
- return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
38
+ return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
28
39
  }
29
40
 
30
41
  onData(chunk) {
31
- this.pos += chunk.length
32
- this.hasher?.update(chunk)
42
+ this.#pos += chunk.length
43
+ this.#hasher?.update(chunk)
33
44
 
34
- return this.handler.onData(chunk)
45
+ return this.#handler.onData(chunk)
35
46
  }
36
47
 
37
48
  onComplete(rawTrailers) {
38
- const hash = this.hasher?.digest('base64')
49
+ const contentMD5 = this.#hasher?.digest('base64')
39
50
 
40
- if (this.length != null && this.pos !== Number(this.length)) {
41
- return this.handler.onError(
51
+ if (this.#contentLength != null && this.#pos !== Number(this.#contentLength)) {
52
+ this.#errored = true
53
+ this.#handler.onError(
42
54
  Object.assign(new Error('Request Content-Length mismatch'), {
43
- expected: Number(this.length),
44
- actual: this.pos,
55
+ expected: Number(this.#contentLength),
56
+ actual: this.#pos,
45
57
  }),
46
58
  )
47
- } else if (this.md5 != null && hash !== this.md5) {
48
- return this.handler.onError(
59
+ } else if (this.#contentMD5 != null && contentMD5 !== this.#contentMD5) {
60
+ this.#errored = true
61
+ this.#handler.onError(
49
62
  Object.assign(new Error('Request Content-MD5 mismatch'), {
50
- expected: this.md5,
51
- actual: hash,
63
+ expected: this.#contentMD5,
64
+ actual: contentMD5,
52
65
  }),
53
66
  )
54
67
  } else {
55
- return this.handler.onComplete(rawTrailers)
68
+ return this.#handler.onComplete(rawTrailers)
56
69
  }
57
70
  }
58
71
 
59
72
  onError(err) {
60
- this.handler.onError(err)
73
+ if (this.#errored) {
74
+ // Do nothing...
75
+ } else {
76
+ this.#handler.onError(err)
77
+ }
61
78
  }
62
79
  }
63
80
 
@@ -1,106 +1,89 @@
1
- import { findHeader, parseHeaders } from '../utils.js'
1
+ import { parseHeaders } from '../utils.js'
2
2
  import createHttpError from 'http-errors'
3
+ import { DecoratorHandler } from 'undici'
3
4
 
4
- class Handler {
5
- handler
6
- opts
5
+ class Handler extends DecoratorHandler {
6
+ #handler
7
7
 
8
- statusCode
9
- contentType
10
- decoder
11
- headers
12
- body
8
+ #statusCode
9
+ #contentType
10
+ #decoder
11
+ #headers
12
+ #body
13
+ #errored
13
14
 
14
15
  constructor(opts, { handler }) {
15
- this.handler = handler
16
- this.opts = opts
17
- }
18
-
19
- onConnect(abort) {
20
- this.statusCode = 0
21
- this.contentType = null
22
- this.decoder = null
23
- this.headers = null
24
- this.body = null
16
+ super(handler)
25
17
 
26
- return this.handler.onConnect(abort)
18
+ this.#handler = handler
27
19
  }
28
20
 
29
- onUpgrade(statusCode, rawHeaders, socket, headers) {
30
- return this.handler.onUpgrade(statusCode, rawHeaders, socket, headers)
21
+ onConnect(abort) {
22
+ this.#statusCode = 0
23
+ this.#contentType = null
24
+ this.#decoder = null
25
+ this.#headers = null
26
+ this.#body = ''
27
+ this.#errored = false
28
+
29
+ return this.#handler.onConnect(abort)
31
30
  }
32
31
 
33
32
  onHeaders(statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
34
- if (statusCode >= 400) {
35
- this.statusCode = statusCode
36
- this.headers = headers
37
- this.contentType = headers ? headers['content-type'] : findHeader(rawHeaders, 'content-type')
38
- if (this.contentType === 'application/json' || this.contentType === 'text/plain') {
39
- this.decoder = new TextDecoder('utf-8')
40
- this.body = ''
33
+ this.#statusCode = statusCode
34
+ this.#headers = headers
35
+ this.#contentType = headers['content-type']
36
+
37
+ if (this.#statusCode >= 400) {
38
+ // TODO (fix): Check content length
39
+ if (this.#contentType === 'application/json' || this.#contentType === 'text/plain') {
40
+ this.#decoder = new TextDecoder('utf-8')
41
41
  }
42
42
  } else {
43
- return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
43
+ return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
44
44
  }
45
45
  }
46
46
 
47
47
  onData(chunk) {
48
- if (this.statusCode) {
49
- if (this.decoder) {
50
- // TODO (fix): Limit body size?
51
- this.body += this.decoder.decode(chunk, { stream: true })
52
- }
53
- return true
48
+ if (this.#statusCode >= 400) {
49
+ this.#body += this.#decoder?.decode(chunk, { stream: true }) ?? ''
54
50
  } else {
55
- return this.handler.onData(chunk)
51
+ return this.#handler.onData(chunk)
56
52
  }
57
53
  }
58
54
 
59
55
  onComplete(rawTrailers) {
60
- this.onFinally(null, rawTrailers)
61
- }
62
-
63
- onError(err) {
64
- this.onFinally(err, null)
65
- }
66
-
67
- onFinally(err, rawTrailers) {
68
- if (this.statusCode >= 400) {
69
- if (this.decoder != null) {
70
- this.body += this.decoder.decode(undefined, { stream: false })
71
- if (this.contentType === 'application/json') {
72
- try {
73
- this.body = JSON.parse(this.body)
74
- } catch {
75
- // Do nothing...
76
- }
56
+ if (this.#statusCode >= 400) {
57
+ this.#body += this.#decoder?.decode(undefined, { stream: false }) ?? ''
58
+
59
+ if (this.#contentType === 'application/json') {
60
+ try {
61
+ this.#body = JSON.parse(this.#body)
62
+ } catch {
63
+ // Do nothing...
77
64
  }
78
65
  }
79
66
 
80
- err = createHttpError(this.statusCode)
81
-
82
- this.decoder = null
83
- this.contentType = null
84
- this.body = null
85
- }
86
-
87
- if (err) {
88
- this.handler.onError(
89
- Object.assign(err, {
90
- ureq: {
91
- origin: this.opts.origin,
92
- path: this.opts.path,
93
- method: this.opts.method,
94
- headers: this.opts.headers,
95
- },
96
- ures: { statusCode: this.statusCode, headers: this.headers, body: this.body },
67
+ this.#errored = true
68
+ this.#handler.onError(
69
+ Object.assign(createHttpError(this.#statusCode), {
70
+ reason: this.#body?.reason,
71
+ error: this.#body?.error,
72
+ headers: this.#headers,
73
+ body: this.#body,
97
74
  }),
98
75
  )
99
76
  } else {
100
- this.handler.onComplete(rawTrailers)
77
+ this.#handler.onComplete(rawTrailers)
101
78
  }
79
+ }
102
80
 
103
- this.handler = null
81
+ onError(err) {
82
+ if (this.#errored) {
83
+ // Do nothing...
84
+ } else {
85
+ this.#handler.onError(err)
86
+ }
104
87
  }
105
88
  }
106
89
 
@@ -1,121 +1,220 @@
1
1
  import assert from 'node:assert'
2
- import { isDisturbed, retry as retryFn } from '../utils.js'
3
-
4
- class Handler {
5
- constructor(opts, { dispatch, handler }) {
6
- this.dispatch = dispatch
7
- this.handler = handler
8
- this.opts = opts
9
-
10
- this.headersSent = false
11
- this.count = 0
12
-
13
- this.reason = null
14
- this.aborted = false
15
-
16
- this.statusCode = null
17
- this.rawHeaders = null
18
- this.resume = null
19
- this.statusMessage = null
20
- this.headers = null
21
-
22
- this.handler.onConnect((reason) => {
23
- this.aborted = true
24
- if (this.abort) {
25
- this.abort(reason)
26
- } else {
27
- this.reason = reason
28
- }
29
- })
2
+ import { isDisturbed, parseHeaders, parseRangeHeader, retry as retryFn } from '../utils.js'
3
+ import { DecoratorHandler } from 'undici'
4
+
5
+ // TODO (fix): What about onUpgrade?
6
+ class Handler extends DecoratorHandler {
7
+ #handler
8
+ #dispatch
9
+ #opts
10
+
11
+ #retryCount = 0
12
+ #headersSent = false
13
+ #errorSent = false
14
+
15
+ #abort
16
+ #aborted = false
17
+ #reason = null
18
+
19
+ #pos
20
+ #end
21
+ #etag
22
+ #error
23
+
24
+ constructor(opts, { handler, dispatch }) {
25
+ super(handler)
26
+
27
+ this.#handler = handler
28
+ this.#dispatch = dispatch
29
+ this.#opts = opts
30
30
  }
31
31
 
32
32
  onConnect(abort) {
33
- if (this.aborted) {
34
- abort(this.reason)
33
+ if (!this.#headersSent) {
34
+ this.#pos = null
35
+ this.#end = null
36
+ this.#etag = null
37
+ this.#error = null
38
+
39
+ this.#handler.onConnect((reason) => {
40
+ if (!this.#aborted) {
41
+ this.#aborted = true
42
+ if (this.#abort) {
43
+ this.#abort(reason)
44
+ } else {
45
+ this.#reason = reason
46
+ }
47
+ }
48
+ })
49
+ }
50
+
51
+ if (this.#aborted) {
52
+ abort(this.#reason)
35
53
  } else {
36
- this.abort = abort
54
+ this.#abort = abort
37
55
  }
38
56
  }
39
57
 
40
- onUpgrade(statusCode, rawHeaders, socket, headers) {
41
- return this.handler.onUpgrade(statusCode, rawHeaders, socket, headers)
42
- }
58
+ onHeaders(statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
59
+ if (this.#error == null) {
60
+ assert(this.#etag == null)
61
+ assert(this.#pos == null)
62
+ assert(this.#end == null)
63
+ assert(this.#headersSent === false)
43
64
 
44
- onHeaders(statusCode, rawHeaders, resume, statusMessage, headers) {
45
- assert(!this.headersSent)
65
+ if (headers.trailer) {
66
+ return this.#onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
67
+ }
46
68
 
47
- this.statusCode = statusCode
48
- this.rawHeaders = rawHeaders
49
- this.resume = resume
50
- this.statusMessage = statusMessage
51
- this.headers = headers
69
+ const contentLength = headers['content-length'] ? Number(headers['content-length']) : null
70
+ if (contentLength != null && !Number.isFinite(contentLength)) {
71
+ return this.#onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
72
+ }
52
73
 
53
- return true
54
- }
74
+ if (statusCode === 206) {
75
+ const range = parseRangeHeader(headers['content-range'])
76
+ if (!range) {
77
+ return this.#onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
78
+ }
55
79
 
56
- onData(chunk) {
57
- if (!this.headersSent) {
58
- this.sendHeaders()
59
- }
80
+ const { start, size, end = size } = range
81
+
82
+ assert(start != null && Number.isFinite(start), 'content-range mismatch')
83
+ assert(end != null && Number.isFinite(end), 'invalid content-length')
84
+ assert(
85
+ contentLength == null || end == null || contentLength === end + 1 - start,
86
+ 'content-range mismatch',
87
+ )
88
+
89
+ this.#pos = start
90
+ this.#end = end ?? contentLength
91
+ this.#etag = headers.etag
92
+ } else if (statusCode === 200) {
93
+ this.#pos = 0
94
+ this.#end = contentLength
95
+ this.#etag = headers.etag
96
+ } else {
97
+ return this.#onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
98
+ }
60
99
 
61
- return this.handler.onData(chunk)
62
- }
100
+ assert(Number.isFinite(this.#pos))
101
+ assert(this.#end == null || Number.isFinite(this.#end))
102
+
103
+ return this.#onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
104
+ } else if (statusCode === 206 || (this.#pos === 0 && statusCode === 200)) {
105
+ assert(this.#etag != null || !this.#pos)
106
+
107
+ const etag = headers.etag
108
+ if (this.#pos > 0 && this.#etag !== etag) {
109
+ throw this.#error
110
+ }
63
111
 
64
- onComplete(rawTrailers) {
65
- if (!this.headersSent) {
66
- this.sendHeaders()
112
+ const contentRange = parseRangeHeader(headers['content-range'])
113
+ if (!contentRange) {
114
+ throw this.#error
115
+ }
116
+
117
+ const { start, size, end = size } = contentRange
118
+ assert(this.#pos === start, 'content-range mismatch')
119
+ assert(this.#end == null || this.#end === end, 'content-range mismatch')
120
+
121
+ // TODO (fix): What if we were paused before the error?
122
+ return true
123
+ } else {
124
+ throw this.#error
67
125
  }
126
+ }
68
127
 
69
- return this.handler.onComplete(rawTrailers)
128
+ onData(chunk) {
129
+ if (this.#pos != null) {
130
+ this.#pos += chunk.byteLength
131
+ }
132
+ return this.#handler.onData(chunk)
70
133
  }
71
134
 
72
135
  onError(err) {
73
- if (this.aborted || this.headersSent || isDisturbed(this.opts.body)) {
74
- return this.handler.onError(err)
136
+ if (this.#aborted || isDisturbed(this.#opts.body) || (this.#pos && !this.#etag)) {
137
+ this.#onError(err)
138
+ return
75
139
  }
76
140
 
77
- const retryPromise = retryFn(err, this.count++, this.opts)
141
+ const retryPromise = retryFn(err, this.#retryCount++, this.#opts)
78
142
  if (retryPromise == null) {
79
- return this.handler.onError(err)
143
+ this.#onError(err)
144
+ return
80
145
  }
81
146
 
82
- this.opts.logger?.debug({ retryCount: this.count }, 'retrying response')
147
+ this.#error = err
83
148
 
84
149
  retryPromise
85
150
  .then(() => {
86
- if (!this.aborted) {
87
- this.dispatch(this.opts, this)
151
+ if (this.#aborted) {
152
+ this.#onError(this.#reason)
153
+ } else if (isDisturbed(this.#opts.body)) {
154
+ this.#onError(this.#error)
155
+ } else if (!this.#headersSent) {
156
+ this.#opts.logger?.debug({ retryCount: this.#retryCount }, 'retry response headers')
157
+ this.#dispatch(this.#opts, this)
158
+ } else {
159
+ assert(Number.isFinite(this.#pos))
160
+ assert(this.#end == null || Number.isFinite(this.#end))
161
+
162
+ this.#opts = {
163
+ ...this.#opts,
164
+ headers: {
165
+ ...this.#opts.headers,
166
+ 'if-match': this.#etag,
167
+ range: `bytes=${this.#pos}-${this.#end ?? ''}`,
168
+ },
169
+ }
170
+
171
+ this.#opts.logger?.debug({ retryCount: this.#retryCount }, 'retry response body')
172
+ this.#dispatch(this.#opts, this)
88
173
  }
89
174
  })
90
175
  .catch((err) => {
91
- if (!this.aborted) {
92
- this.handler.onError(err)
176
+ if (!this.#errorSent) {
177
+ this.#onError(err)
93
178
  }
94
179
  })
95
180
  }
96
181
 
97
- sendHeaders() {
98
- assert(!this.headersSent)
99
-
100
- this.headersSent = true
101
- this.handler.onHeaders(
102
- this.statusCode,
103
- this.rawHeaders,
104
- this.resume,
105
- this.statusMessage,
106
- this.headers,
107
- )
108
-
109
- this.statusCode = null
110
- this.rawHeaders = null
111
- this.resume = null
112
- this.statusMessage = null
113
- this.headers = null
182
+ #onError(err) {
183
+ assert(!this.#errorSent)
184
+ this.#errorSent = true
185
+ this.#handler.onError(err)
186
+ }
187
+
188
+ #onHeaders(...args) {
189
+ assert(!this.#headersSent)
190
+ this.#headersSent = true
191
+ return this.#handler.onHeaders(...args)
114
192
  }
115
193
  }
116
194
 
117
195
  export default (dispatch) => (opts, handler) => {
118
- return opts.retry
196
+ return opts.idempotent && opts.retry && opts.method === 'GET' && !opts.upgrade
119
197
  ? dispatch(opts, new Handler(opts, { handler, dispatch }))
120
198
  : dispatch(opts, handler)
121
199
  }
200
+
201
+ export function isConnectionError(err) {
202
+ // AWS compat.
203
+ const statusCode = err?.statusCode ?? err?.$metadata?.httpStatusCode
204
+ return err
205
+ ? err.code === 'ECONNRESET' ||
206
+ err.code === 'ECONNREFUSED' ||
207
+ err.code === 'ENOTFOUND' ||
208
+ err.code === 'ENETDOWN' ||
209
+ err.code === 'ENETUNREACH' ||
210
+ err.code === 'EHOSTDOWN' ||
211
+ err.code === 'EHOSTUNREACH' ||
212
+ err.code === 'EPIPE' ||
213
+ err.message === 'other side closed' ||
214
+ statusCode === 420 ||
215
+ statusCode === 429 ||
216
+ statusCode === 502 ||
217
+ statusCode === 503 ||
218
+ statusCode === 504
219
+ : false
220
+ }
package/lib/utils.js CHANGED
@@ -6,6 +6,21 @@ export function parseCacheControl(str) {
6
6
  return str ? cacheControlParser.parse(str) : null
7
7
  }
8
8
 
9
+ // Parsed accordingly to RFC 9110
10
+ // https://www.rfc-editor.org/rfc/rfc9110#field.content-range
11
+ export function parseRangeHeader(range) {
12
+ if (range == null || range === '') return { start: 0, end: null, size: null }
13
+
14
+ const m = range ? range.match(/^bytes (\d+)-(\d+)\/(\d+)?$/) : null
15
+ return m
16
+ ? {
17
+ start: parseInt(m[1]),
18
+ end: m[2] ? parseInt(m[2]) : null,
19
+ size: m[3] ? parseInt(m[3]) : null,
20
+ }
21
+ : null
22
+ }
23
+
9
24
  export function isDisturbed(body) {
10
25
  if (
11
26
  body == null ||
@@ -78,12 +93,20 @@ export function findHeader(headers, name) {
78
93
  }
79
94
 
80
95
  export function retry(err, retryCount, opts) {
81
- if (opts.retry === null || opts.retry === false) {
96
+ if (!opts.retry) {
82
97
  return null
83
98
  }
84
99
 
85
100
  if (typeof opts.retry === 'function') {
86
- return opts.retry(err, retryCount, opts)
101
+ try {
102
+ return opts.retry(err, retryCount, opts)
103
+ } catch (err) {
104
+ return Promise.reject(err)
105
+ }
106
+ }
107
+
108
+ if (typeof opts.retry === 'number') {
109
+ opts = { count: opts.retry }
87
110
  }
88
111
 
89
112
  const retryMax = opts.retry?.count ?? opts.maxRetries ?? 8
@@ -92,8 +115,10 @@ export function retry(err, retryCount, opts) {
92
115
  return null
93
116
  }
94
117
 
95
- if (err.statusCode && [420, 429, 502, 503, 504].includes(err.statusCode)) {
96
- let retryAfter = err.headers['retry-after'] ? err.headers['retry-after'] * 1e3 : null
118
+ const statusCode = err.statusCode ?? err.status ?? err.$metadata?.httpStatusCode ?? null
119
+
120
+ if (statusCode && [420, 429, 502, 503, 504].includes(statusCode)) {
121
+ let retryAfter = err.headers?.['retry-after'] ? err.headers['retry-after'] * 1e3 : null
97
122
  retryAfter = Number.isFinite(retryAfter) ? retryAfter : Math.min(10e3, retryCount * 1e3)
98
123
  if (retryAfter != null && Number.isFinite(retryAfter)) {
99
124
  return tp.setTimeout(retryAfter, undefined, { signal: opts.signal })
@@ -199,7 +224,7 @@ export function parseOrigin(url) {
199
224
  }
200
225
 
201
226
  export function parseHeaders(headers, obj = {}) {
202
- return util.parseHeaders(headers, obj)
227
+ return Array.isArray(headers) ? util.parseHeaders(headers, obj) : headers
203
228
  }
204
229
 
205
230
  export class AbortError extends Error {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "2.0.46",
3
+ "version": "2.0.47",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",
@@ -1,193 +0,0 @@
1
- import assert from 'node:assert'
2
- import { parseContentRange, isDisturbed, findHeader, retry as retryFn } from '../utils.js'
3
-
4
- class Handler {
5
- constructor(opts, { dispatch, handler }) {
6
- this.dispatch = dispatch
7
- this.handler = handler
8
- this.opts = opts
9
-
10
- this.count = 0
11
- this.pos = 0
12
- this.end = null
13
- this.error = null
14
- this.etag = null
15
-
16
- this.headersSent = false
17
-
18
- this.reason = null
19
- this.aborted = false
20
-
21
- this.handler.onConnect((reason) => {
22
- this.aborted = true
23
- if (this.abort) {
24
- this.abort(reason)
25
- } else {
26
- this.reason = reason
27
- }
28
- })
29
- }
30
-
31
- onConnect(abort) {
32
- if (this.aborted) {
33
- abort(this.reason)
34
- } else {
35
- this.abort = abort
36
- }
37
- }
38
-
39
- onUpgrade(statusCode, rawHeaders, socket, headers) {
40
- return this.handler.onUpgrade(statusCode, rawHeaders, socket, headers)
41
- }
42
-
43
- onHeaders(statusCode, rawHeaders, resume, statusMessage, headers) {
44
- const etag = headers ? headers.etag : findHeader(rawHeaders, 'etag')
45
-
46
- if (this.resume) {
47
- this.resume = null
48
-
49
- // TODO (fix): Support other statusCode with skip?
50
- if (statusCode !== 206) {
51
- throw this.error
52
- }
53
-
54
- // TODO (fix): strict vs weak etag?
55
- if (this.etag == null || this.etag !== etag) {
56
- throw this.error
57
- }
58
-
59
- const contentRange = parseContentRange(
60
- headers ? headers['content-range'] : findHeader(rawHeaders, 'content-range'),
61
- )
62
- if (!contentRange) {
63
- throw this.error
64
- }
65
-
66
- const { start, size, end = size } = contentRange
67
-
68
- assert(this.pos === start, 'content-range mismatch')
69
- assert(this.end == null || this.end === end, 'content-range mismatch')
70
-
71
- this.resume = resume
72
- return true
73
- }
74
-
75
- if (this.end == null) {
76
- if (statusCode === 206) {
77
- const contentRange = parseContentRange(
78
- headers ? headers['content-range'] : findHeader(rawHeaders, 'content-range'),
79
- )
80
- if (!contentRange) {
81
- assert(!this.headersSent)
82
- this.headersSent = true
83
- return this.handler.onHeaders(
84
- statusCode,
85
- rawHeaders,
86
- () => this.resume(),
87
- statusMessage,
88
- headers,
89
- )
90
- }
91
-
92
- const { start, size, end = size } = contentRange
93
-
94
- this.end = end
95
- this.pos = Number(start)
96
- } else {
97
- const contentLength = headers
98
- ? headers['content-length']
99
- : findHeader(rawHeaders, 'content-length')
100
- if (contentLength) {
101
- this.end = Number(contentLength)
102
- }
103
- }
104
-
105
- assert(Number.isFinite(this.pos))
106
- assert(this.end == null || Number.isFinite(this.end), 'invalid content-length')
107
- }
108
-
109
- this.etag = etag
110
- this.resume = resume
111
-
112
- assert(!this.headersSent)
113
- this.headersSent = true
114
- return this.handler.onHeaders(
115
- statusCode,
116
- rawHeaders,
117
- () => this.resume(),
118
- statusMessage,
119
- headers,
120
- )
121
- }
122
-
123
- onData(chunk) {
124
- this.pos += chunk.length
125
- this.count = 0
126
- return this.handler.onData(chunk)
127
- }
128
-
129
- onComplete(rawTrailers) {
130
- return this.handler.onComplete(rawTrailers)
131
- }
132
-
133
- onError(err) {
134
- if (this.aborted || !this.etag || isDisturbed(this.opts.body)) {
135
- return this.handler.onError(err)
136
- }
137
-
138
- const retryPromise = retryFn(err, this.count++, this.opts)
139
- if (retryPromise == null) {
140
- return this.handler.onError(err)
141
- }
142
-
143
- this.error = err
144
- this.opts = {
145
- ...this.opts,
146
- headers: {
147
- ...this.opts.headers,
148
- 'if-match': this.etag,
149
- range: `bytes=${this.pos}-${this.end ?? ''}`,
150
- },
151
- }
152
-
153
- this.opts.logger?.debug({ retryCount: this.count }, 'retrying response body')
154
-
155
- retryPromise
156
- .then(() => {
157
- if (!this.aborted) {
158
- this.dispatch(this.opts, this)
159
- }
160
- })
161
- .catch((err) => {
162
- if (!this.aborted) {
163
- this.handler.onError(err)
164
- }
165
- })
166
- }
167
- }
168
-
169
- export default (dispatch) => (opts, handler) => {
170
- return opts.idempotent && opts.retry && opts.method === 'GET' && !opts.upgrade
171
- ? dispatch(opts, new Handler(opts, { handler, dispatch }))
172
- : dispatch(opts, handler)
173
- }
174
- export function isConnectionError(err) {
175
- // AWS compat.
176
- const statusCode = err?.statusCode ?? err?.$metadata?.httpStatusCode
177
- return err
178
- ? err.code === 'ECONNRESET' ||
179
- err.code === 'ECONNREFUSED' ||
180
- err.code === 'ENOTFOUND' ||
181
- err.code === 'ENETDOWN' ||
182
- err.code === 'ENETUNREACH' ||
183
- err.code === 'EHOSTDOWN' ||
184
- err.code === 'EHOSTUNREACH' ||
185
- err.code === 'EPIPE' ||
186
- err.message === 'other side closed' ||
187
- statusCode === 420 ||
188
- statusCode === 429 ||
189
- statusCode === 502 ||
190
- statusCode === 503 ||
191
- statusCode === 504
192
- : false
193
- }