@nxtedition/nxt-undici 2.0.47 → 2.0.49

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
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert'
2
2
  import createError from 'http-errors'
3
3
  import undici from 'undici'
4
- import { findHeader, parseHeaders, AbortError, isStream } from './utils.js'
4
+ import { parseHeaders, AbortError, headerNameToString, isStream } from './utils.js'
5
5
  import { BodyReadable } from './readable.js'
6
6
 
7
7
  const dispatcherCache = new WeakMap()
@@ -61,17 +61,18 @@ export async function request(url, opts) {
61
61
  const method = opts.method ?? (opts.body ? 'POST' : 'GET')
62
62
  const idempotent = opts.idempotent ?? (method === 'GET' || method === 'HEAD')
63
63
 
64
- let headers
64
+ let headers = {}
65
65
  if (Array.isArray(opts.headers)) {
66
66
  headers = parseHeaders(opts.headers)
67
67
  } else if (opts.headers != null) {
68
- // TODO (fix): Object.values(opts.headers)?
69
- headers = opts.headers
68
+ for (const [key, val] of Object.entries(opts.headers)) {
69
+ headers[headerNameToString(key)] = val
70
+ }
70
71
  }
71
72
 
72
73
  const userAgent = opts.userAgent ?? globalThis.userAgent
73
74
  if (userAgent && headers?.['user-agent'] !== userAgent) {
74
- headers = { 'user-agent': userAgent, ...headers }
75
+ headers['user-agent'] = userAgent
75
76
  }
76
77
 
77
78
  if (method === 'CONNECT') {
@@ -86,6 +87,14 @@ export async function request(url, opts) {
86
87
  throw new createError.BadRequest('HEAD and GET cannot have body')
87
88
  }
88
89
 
90
+ if (
91
+ opts.body != null &&
92
+ (opts.body.size > 0 || opts.body.length > 0) &&
93
+ (method === 'HEAD' || method === 'GET')
94
+ ) {
95
+ throw new createError.BadRequest('HEAD and GET cannot have body')
96
+ }
97
+
89
98
  const expectsPayload = opts.method === 'PUT' || opts.method === 'POST' || opts.method === 'PATCH'
90
99
 
91
100
  if (headers != null && headers['content-length'] === '0' && !expectsPayload) {
@@ -124,7 +133,7 @@ export async function request(url, opts) {
124
133
  return await new Promise((resolve, reject) =>
125
134
  dispatch(
126
135
  {
127
- id: opts.id ?? findHeader(headers, 'request-id') ?? genReqId(),
136
+ id: opts.id ?? headers['request-id'] ?? headers['Request-Id'] ?? genReqId(),
128
137
  url,
129
138
  method,
130
139
  body: opts.body,
@@ -187,8 +196,8 @@ export async function request(url, opts) {
187
196
  ) {
188
197
  assert(statusCode >= 200)
189
198
 
190
- const contentLength = findHeader(headers, 'content-length')
191
- const contentType = findHeader(headers, 'content-type')
199
+ const contentLength = headers['content-length']
200
+ const contentType = headers['content-type']
192
201
 
193
202
  this.body = new BodyReadable(this, {
194
203
  resume,
@@ -201,6 +210,13 @@ export async function request(url, opts) {
201
210
  size: Number.isFinite(contentLength) ? contentLength : null,
202
211
  })
203
212
 
213
+ if (this.signal) {
214
+ this.body.on('close', () => {
215
+ this.signal?.removeEventListener('abort', this.onAbort)
216
+ this.signal = null
217
+ })
218
+ }
219
+
204
220
  this.resolve(this.body)
205
221
  this.resolve = null
206
222
  this.reject = null
@@ -9,7 +9,8 @@ class Handler extends DecoratorHandler {
9
9
  #aborted = false
10
10
  #logger
11
11
  #pos
12
- #stats
12
+ #timing
13
+ #startTime
13
14
 
14
15
  constructor(opts, { handler }) {
15
16
  super(handler)
@@ -17,24 +18,18 @@ class Handler extends DecoratorHandler {
17
18
  this.#handler = handler
18
19
  this.#opts = opts
19
20
  this.#logger = opts.logger.child({ ureq: { id: opts.id } })
20
- this.#stats = {
21
- created: performance.now(),
22
- start: -1,
23
- end: -1,
24
- headers: -1,
25
- firstBodyReceived: -1,
26
- lastBodyReceived: -1,
27
- }
28
21
  }
29
22
 
30
23
  onConnect(abort) {
31
24
  this.#pos = 0
32
25
  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
26
+ this.#timing = {
27
+ headers: -1,
28
+ data: -1,
29
+ complete: -1,
30
+ error: -1,
31
+ }
32
+ this.#startTime = performance.now()
38
33
 
39
34
  this.#logger.debug({ ureq: this.#opts }, 'upstream request started')
40
35
 
@@ -54,12 +49,12 @@ class Handler extends DecoratorHandler {
54
49
  }
55
50
 
56
51
  onHeaders(statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
57
- this.#stats.headers = performance.now() - this.#stats.start
52
+ this.#timing.headers = performance.now() - this.#startTime
58
53
 
59
54
  this.#logger.debug(
60
55
  {
61
56
  ures: { statusCode, headers },
62
- elapsedTime: this.#stats.headers,
57
+ elapsedTime: this.#timing.headers,
63
58
  },
64
59
  'upstream request response',
65
60
  )
@@ -68,8 +63,8 @@ class Handler extends DecoratorHandler {
68
63
  }
69
64
 
70
65
  onData(chunk) {
71
- if (this.#stats.firstBodyReceived === -1) {
72
- this.#stats.firstBodyReceived = performance.now() - this.#stats.start
66
+ if (this.#timing.data === -1) {
67
+ this.#timing.data = performance.now() - this.#startTime
73
68
  }
74
69
 
75
70
  this.#pos += chunk.length
@@ -78,11 +73,10 @@ class Handler extends DecoratorHandler {
78
73
  }
79
74
 
80
75
  onComplete(rawTrailers) {
81
- this.#stats.lastBodyReceived = performance.now() - this.#stats.start
82
- this.#stats.end = this.#stats.lastBodyReceived
76
+ this.#timing.complete = performance.now() - this.#startTime
83
77
 
84
78
  this.#logger.debug(
85
- { bytesRead: this.#pos, elapsedTime: this.#stats.end, stats: this.#stats },
79
+ { elapsedTime: this.#timing.complete, bytesRead: this.#pos, timing: this.#timing },
86
80
  'upstream request completed',
87
81
  )
88
82
 
@@ -90,17 +84,15 @@ class Handler extends DecoratorHandler {
90
84
  }
91
85
 
92
86
  onError(err) {
93
- if (this.#stats) {
94
- this.#stats.end = performance.now() - this.#stats.start
95
- }
87
+ this.#timing.error = performance.now() - this.#startTime
96
88
 
97
89
  if (this.#aborted) {
98
90
  this.#logger.debug(
99
91
  {
100
92
  ureq: this.#opts,
101
93
  bytesRead: this.#pos,
102
- elapsedTime: this.#stats.end,
103
- stats: this.#stats,
94
+ timing: this.#timing,
95
+ elapsedTime: this.#timing.error,
104
96
  err,
105
97
  },
106
98
  'upstream request aborted',
@@ -110,8 +102,8 @@ class Handler extends DecoratorHandler {
110
102
  {
111
103
  ureq: this.#opts,
112
104
  bytesRead: this.#pos,
113
- elapsedTime: this.#stats.end,
114
- stats: this.#stats,
105
+ timing: this.#timing,
106
+ elapsedTime: this.#timing.error,
115
107
  err,
116
108
  },
117
109
  'upstream request failed',
@@ -1,97 +1,101 @@
1
1
  import assert from 'node:assert'
2
- import { findHeader, isDisturbed, parseURL } from '../utils.js'
2
+ import { isDisturbed, parseHeaders, parseURL } from '../utils.js'
3
3
  import { DecoratorHandler } from 'undici'
4
4
 
5
5
  const redirectableStatusCodes = [300, 301, 302, 303, 307, 308]
6
6
 
7
7
  class Handler extends DecoratorHandler {
8
+ #dispatch
9
+ #handler
10
+
11
+ #opts
12
+ #abort
13
+ #aborted = false
14
+ #reason
15
+ #maxCount
16
+ #headersSent = false
17
+ #count = 0
18
+ #location
19
+ #history = []
20
+
8
21
  constructor(opts, { dispatch, handler }) {
9
22
  super(handler)
10
23
 
11
- this.dispatch = dispatch
12
- this.handler = handler
13
- this.opts = opts
14
- this.abort = null
15
- this.aborted = false
16
- this.reason = null
17
- this.maxCount = Number.isFinite(opts.follow) ? opts.follow : opts.follow?.count ?? 0
18
-
19
- this.headersSent = false
20
-
21
- this.count = 0
22
- this.location = null
23
- this.history = []
24
+ this.#dispatch = dispatch
25
+ this.#handler = handler
26
+ this.#opts = opts
27
+ this.#maxCount = Number.isFinite(opts.follow) ? opts.follow : opts.follow?.count ?? 0
24
28
 
25
- this.handler.onConnect((reason) => {
26
- this.aborted = true
27
- if (this.abort) {
28
- this.abort(reason)
29
+ this.#handler.onConnect((reason) => {
30
+ this.#aborted = true
31
+ if (this.#abort) {
32
+ this.#abort(reason)
29
33
  } else {
30
- this.reason = reason
34
+ this.#reason = reason
31
35
  }
32
36
  })
33
37
  }
34
38
 
35
39
  onConnect(abort) {
36
- if (this.aborted) {
37
- abort(this.reason)
40
+ if (this.#aborted) {
41
+ abort(this.#reason)
38
42
  } else {
39
- this.abort = abort
43
+ this.#abort = abort
40
44
  }
41
45
  }
42
46
 
43
47
  onUpgrade(statusCode, rawHeaders, socket, headers) {
44
- return this.handler.onUpgrade(statusCode, rawHeaders, socket, headers)
48
+ return this.#handler.onUpgrade(statusCode, rawHeaders, socket, headers)
45
49
  }
46
50
 
47
- onHeaders(statusCode, rawHeaders, resume, statusText, headers) {
51
+ onHeaders(statusCode, rawHeaders, resume, statusText, headers = parseHeaders(rawHeaders)) {
48
52
  if (redirectableStatusCodes.indexOf(statusCode) === -1) {
49
- assert(!this.headersSent)
50
- this.headersSent = true
51
- return this.handler.onHeaders(statusCode, rawHeaders, resume, statusText, headers)
53
+ assert(!this.#headersSent)
54
+ this.#headersSent = true
55
+ return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusText, headers)
52
56
  }
53
57
 
54
- if (isDisturbed(this.opts.body)) {
58
+ if (isDisturbed(this.#opts.body)) {
55
59
  throw new Error(`Disturbed request cannot be redirected.`)
56
60
  }
57
61
 
58
- this.location = headers ? headers.location : findHeader(rawHeaders, 'location')
62
+ this.#location = headers.location
59
63
 
60
- if (!this.location) {
64
+ if (!this.#location) {
61
65
  throw new Error(`Missing redirection location .`)
62
66
  }
63
67
 
64
- this.history.push(this.location)
65
- this.count += 1
68
+ this.#history.push(this.#location)
69
+ this.#count += 1
66
70
 
67
- if (typeof this.opts.follow === 'function') {
68
- if (!this.opts.follow(this.location, this.count)) {
69
- assert(!this.headersSent)
70
- this.headersSent = true
71
- return this.handler.onHeaders(statusCode, rawHeaders, resume, statusText, headers)
71
+ if (typeof this.#opts.follow === 'function') {
72
+ if (!this.#opts.follow(this.#location, this.#count)) {
73
+ assert(!this.#headersSent)
74
+ this.#headersSent = true
75
+ return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusText, headers)
72
76
  }
73
77
  } else {
74
- if (this.count >= this.maxCount) {
75
- throw Object.assign(new Error(`Max redirections reached: ${this.maxCount}.`), {
76
- history: this.history,
78
+ if (this.#count >= this.#maxCount) {
79
+ throw Object.assign(new Error(`Max redirections reached: ${this.#maxCount}.`), {
80
+ history: this.#history,
77
81
  })
78
82
  }
79
83
  }
80
84
 
81
85
  const { origin, pathname, search } = parseURL(
82
- new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin)),
86
+ new URL(this.#location, this.#opts.origin && new URL(this.#opts.path, this.#opts.origin)),
83
87
  )
84
88
  const path = search ? `${pathname}${search}` : pathname
85
89
 
86
90
  // Remove headers referring to the original URL.
87
91
  // By default it is Host only, unless it's a 303 (see below), which removes also all Content-* headers.
88
92
  // https://tools.ietf.org/html/rfc7231#section-6.4
89
- this.opts = {
90
- ...this.opts,
93
+ this.#opts = {
94
+ ...this.#opts,
91
95
  headers: cleanRequestHeaders(
92
- this.opts.headers,
96
+ this.#opts.headers,
93
97
  statusCode === 303,
94
- this.opts.origin !== origin,
98
+ this.#opts.origin !== origin,
95
99
  ),
96
100
  path,
97
101
  origin,
@@ -100,13 +104,13 @@ class Handler extends DecoratorHandler {
100
104
 
101
105
  // https://tools.ietf.org/html/rfc7231#section-6.4.4
102
106
  // In case of HTTP 303, always replace method to be either HEAD or GET
103
- if (statusCode === 303 && this.opts.method !== 'HEAD') {
104
- this.opts = { ...this.opts, method: 'GET', body: null }
107
+ if (statusCode === 303 && this.#opts.method !== 'HEAD') {
108
+ this.#opts = { ...this.#opts, method: 'GET', body: null }
105
109
  }
106
110
  }
107
111
 
108
112
  onData(chunk) {
109
- if (this.location) {
113
+ if (this.#location) {
110
114
  /*
111
115
  https://tools.ietf.org/html/rfc7231#section-6.4
112
116
 
@@ -125,12 +129,12 @@ class Handler extends DecoratorHandler {
125
129
  servers and browsers implementors, we ignore the body as there is no specified way to eventually parse it.
126
130
  */
127
131
  } else {
128
- return this.handler.onData(chunk)
132
+ return this.#handler.onData(chunk)
129
133
  }
130
134
  }
131
135
 
132
136
  onComplete(trailers) {
133
- if (this.location) {
137
+ if (this.#location) {
134
138
  /*
135
139
  https://tools.ietf.org/html/rfc7231#section-6.4
136
140
 
@@ -140,16 +144,16 @@ class Handler extends DecoratorHandler {
140
144
  See comment on onData method above for more detailed informations.
141
145
  */
142
146
 
143
- this.location = null
147
+ this.#location = null
144
148
 
145
- this.dispatch(this.opts, this)
149
+ this.#dispatch(this.#opts, this)
146
150
  } else {
147
- return this.handler.onComplete(trailers)
151
+ return this.#handler.onComplete(trailers)
148
152
  }
149
153
  }
150
154
 
151
155
  onError(error) {
152
- return this.handler.onError(error)
156
+ return this.#handler.onError(error)
153
157
  }
154
158
  }
155
159
 
@@ -12,7 +12,7 @@ function genReqId() {
12
12
  }
13
13
 
14
14
  export default (dispatch) => (opts, handler) => {
15
- let id = opts.id ?? opts.headers?.['request-id'] ?? opts.headers?.['Request-Id']
15
+ let id = opts.id ?? opts.headers?.['request-id']
16
16
  id = id ? `${id},${genReqId()}` : genReqId()
17
17
 
18
18
  return dispatch(
@@ -31,8 +31,8 @@ class Handler extends DecoratorHandler {
31
31
  }
32
32
 
33
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']
34
+ this.#contentMD5 = headers['content-md5']
35
+ this.#contentLength = headers['content-length']
36
36
  this.#hasher = this.#contentMD5 != null ? crypto.createHash('md5') : null
37
37
 
38
38
  return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
@@ -34,14 +34,14 @@ class Handler extends DecoratorHandler {
34
34
  this.#headers = headers
35
35
  this.#contentType = headers['content-type']
36
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
- }
42
- } else {
37
+ if (this.#statusCode < 400) {
43
38
  return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
44
39
  }
40
+
41
+ // TODO (fix): Check content length
42
+ if (this.#contentType === 'application/json' || this.#contentType === 'text/plain') {
43
+ this.#decoder = new TextDecoder('utf-8')
44
+ }
45
45
  }
46
46
 
47
47
  onData(chunk) {
@@ -65,14 +65,22 @@ class Handler extends DecoratorHandler {
65
65
  }
66
66
 
67
67
  this.#errored = true
68
- this.#handler.onError(
69
- Object.assign(createHttpError(this.#statusCode), {
68
+
69
+ let err
70
+
71
+ const stackTraceLimit = Error.stackTraceLimit
72
+ Error.stackTraceLimit = 0
73
+ try {
74
+ err = Object.assign(createHttpError(this.#statusCode), {
70
75
  reason: this.#body?.reason,
71
76
  error: this.#body?.error,
72
77
  headers: this.#headers,
73
78
  body: this.#body,
74
- }),
75
- )
79
+ })
80
+ } finally {
81
+ Error.stackTraceLimit = stackTraceLimit
82
+ }
83
+ this.#handler.onError(err)
76
84
  } else {
77
85
  this.#handler.onComplete(rawTrailers)
78
86
  }
@@ -97,6 +97,13 @@ class Handler extends DecoratorHandler {
97
97
  return this.#onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
98
98
  }
99
99
 
100
+ // Weak etags are not useful for comparison nor cache
101
+ // for instance not safe to assume if the response is byte-per-byte
102
+ // equal
103
+ if (this.#etag != null && this.#etag.startsWith('W/')) {
104
+ this.#etag = null
105
+ }
106
+
100
107
  assert(Number.isFinite(this.#pos))
101
108
  assert(this.#end == null || Number.isFinite(this.#end))
102
109
 
@@ -104,8 +111,7 @@ class Handler extends DecoratorHandler {
104
111
  } else if (statusCode === 206 || (this.#pos === 0 && statusCode === 200)) {
105
112
  assert(this.#etag != null || !this.#pos)
106
113
 
107
- const etag = headers.etag
108
- if (this.#pos > 0 && this.#etag !== etag) {
114
+ if (this.#pos > 0 && this.#etag !== headers.etag) {
109
115
  throw this.#error
110
116
  }
111
117
 
@@ -138,7 +144,7 @@ class Handler extends DecoratorHandler {
138
144
  return
139
145
  }
140
146
 
141
- const retryPromise = retryFn(err, this.#retryCount++, this.#opts)
147
+ const retryPromise = retryFn(err, this.#retryCount++, { ...this.#opts.retry })
142
148
  if (retryPromise == null) {
143
149
  this.#onError(err)
144
150
  return
@@ -193,7 +199,8 @@ class Handler extends DecoratorHandler {
193
199
  }
194
200
 
195
201
  export default (dispatch) => (opts, handler) => {
196
- return opts.idempotent && opts.retry && opts.method === 'GET' && !opts.upgrade
202
+ // TODO (fix): HEAD, PUT, PATCH, DELETE, OPTIONS?
203
+ return opts.retry && opts.method === 'GET' && !opts.upgrade
197
204
  ? dispatch(opts, new Handler(opts, { handler, dispatch }))
198
205
  : dispatch(opts, handler)
199
206
  }
package/lib/readable.js CHANGED
@@ -90,7 +90,7 @@ export class BodyReadable extends Readable {
90
90
  this[kHandler].signal = null
91
91
  }
92
92
 
93
- if (err) {
93
+ if (!this[kReading]) {
94
94
  // Workaround for Node "bug". If the stream is destroyed in same
95
95
  // tick as it is created, then a user who is waiting for a
96
96
  // promise (i.e micro tick) for installing a 'error' listener will
package/lib/utils.js CHANGED
@@ -6,6 +6,10 @@ export function parseCacheControl(str) {
6
6
  return str ? cacheControlParser.parse(str) : null
7
7
  }
8
8
 
9
+ export function headerNameToString(name) {
10
+ return util.headerNameToString(name)
11
+ }
12
+
9
13
  // Parsed accordingly to RFC 9110
10
14
  // https://www.rfc-editor.org/rfc/rfc9110#field.content-range
11
15
  export function parseRangeHeader(range) {
@@ -66,50 +70,24 @@ export function parseContentRange(range) {
66
70
  return { start, end: end ? end + 1 : size, size }
67
71
  }
68
72
 
69
- export function findHeader(headers, name) {
70
- const len = name.length
71
-
72
- if (Array.isArray(headers)) {
73
- for (let i = 0; i < headers.length; i += 2) {
74
- const key = headers[i + 0]
75
- if (key.length === len && util.headerNameToString(key) === name) {
76
- return headers[i + 1]?.toString()
77
- }
78
- }
79
- } else if (headers != null) {
80
- const val = headers[name]
81
- if (val !== undefined) {
82
- return val
83
- }
84
-
85
- for (const key of Object.keys(headers)) {
86
- if (key.length === len && util.headerNameToString(key) === name) {
87
- return headers[key]?.toString()
88
- }
89
- }
90
- }
91
-
92
- return null
93
- }
94
-
95
73
  export function retry(err, retryCount, opts) {
96
- if (!opts.retry) {
74
+ if (!opts) {
97
75
  return null
98
76
  }
99
77
 
100
- if (typeof opts.retry === 'function') {
78
+ if (typeof opts === 'function') {
101
79
  try {
102
- return opts.retry(err, retryCount, opts)
80
+ return opts(err, retryCount, opts, (opts) => retry(err, retryCount, opts))
103
81
  } catch (err) {
104
82
  return Promise.reject(err)
105
83
  }
106
84
  }
107
85
 
108
- if (typeof opts.retry === 'number') {
109
- opts = { count: opts.retry }
86
+ if (typeof opts === 'number') {
87
+ opts = { count: opts }
110
88
  }
111
89
 
112
- const retryMax = opts.retry?.count ?? opts.maxRetries ?? 8
90
+ const retryMax = opts?.count ?? 8
113
91
 
114
92
  if (retryCount > retryMax) {
115
93
  return null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "2.0.47",
3
+ "version": "2.0.49",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",