@nxtedition/nxt-undici 1.0.4 → 1.0.5

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
@@ -4,18 +4,7 @@ const undici = require('undici')
4
4
  const stream = require('stream')
5
5
  const { parseHeaders } = require('./utils')
6
6
 
7
- // https://github.com/fastify/fastify/blob/main/lib/reqIdGenFactory.js
8
- // 2,147,483,647 (2^31 − 1) stands for max SMI value (an internal optimization of V8).
9
- // With this upper bound, if you'll be generating 1k ids/sec, you're going to hit it in ~25 days.
10
- // This is very likely to happen in real-world applications, hence the limit is enforced.
11
- // Growing beyond this value will make the id generation slower and cause a deopt.
12
- // In the worst cases, it will become a float, losing accuracy.
13
- const maxInt = 2147483647
14
- let nextReqId = Math.floor(Math.random() * maxInt)
15
- function genReqId() {
16
- nextReqId = (nextReqId + 1) & maxInt
17
- return `req-${nextReqId.toString(36)}`
18
- }
7
+ const dispatcherCache = new WeakMap()
19
8
 
20
9
  class Readable extends stream.Readable {
21
10
  constructor({ statusCode, statusMessage, headers, size, ...opts }) {
@@ -87,6 +76,7 @@ const dispatchers = {
87
76
  signal: require('./interceptor/signal.js'),
88
77
  proxy: require('./interceptor/proxy.js'),
89
78
  cache: require('./interceptor/cache.js'),
79
+ requestId: require('./interceptor/request-id.js'),
90
80
  }
91
81
 
92
82
  async function request(url, opts) {
@@ -114,10 +104,9 @@ async function request(url, opts) {
114
104
  headers = opts.headers
115
105
  }
116
106
 
117
- headers = {
118
- 'request-id': genReqId(),
119
- 'user-agent': opts.userAgent ?? globalThis.userAgent,
120
- ...headers,
107
+ const userAgent = opts.userAgent ?? globalThis.userAgent
108
+ if (userAgent && headers?.['user-agent'] !== userAgent) {
109
+ headers = { 'user-agent': userAgent, ...headers }
121
110
  }
122
111
 
123
112
  if (method === 'CONNECT') {
@@ -125,127 +114,138 @@ async function request(url, opts) {
125
114
  }
126
115
 
127
116
  if (
117
+ headers != null &&
128
118
  (method === 'HEAD' || method === 'GET') &&
129
119
  (parseInt(headers['content-length']) > 0 || headers['transfer-encoding'])
130
120
  ) {
131
121
  throw new createError.BadRequest('HEAD and GET cannot have body')
132
122
  }
133
123
 
134
- opts = {
135
- url,
136
- method,
137
- body: opts.body,
138
- headers,
139
- origin: url.origin,
140
- path: url.path ? url.path : url.search ? `${url.pathname}${url.search ?? ''}` : url.pathname,
141
- reset: opts.reset ?? false,
142
- headersTimeout: opts.headersTimeout,
143
- bodyTimeout: opts.bodyTimeout,
144
- idempotent,
145
- signal: opts.signal,
146
- retry: opts.retry ?? 8,
147
- proxy: opts.proxy,
148
- cache: opts.cache,
149
- upgrade: opts.upgrade,
150
- follow: { count: opts.maxRedirections ?? 8, ...opts.redirect, ...opts.follow },
151
- logger: opts.logger,
152
- maxRedirections: 0, // Disable undici's redirect handling.
153
- }
154
-
155
124
  const expectsPayload = opts.method === 'PUT' || opts.method === 'POST' || opts.method === 'PATCH'
156
125
 
157
- if (opts.headers['content-length'] === '0' && !expectsPayload) {
126
+ if (headers != null && headers['content-length'] === '0' && !expectsPayload) {
158
127
  // https://tools.ietf.org/html/rfc7230#section-3.3.2
159
128
  // A user agent SHOULD NOT send a Content-Length header field when
160
129
  // the request message does not contain a payload body and the method
161
130
  // semantics do not anticipate such a body.
162
131
 
163
132
  // undici will error if provided an unexpected content-length: 0 header.
164
- delete opts.headers['content-length']
133
+ headers = { ...headers }
134
+ delete headers['content-length']
165
135
  }
166
136
 
167
137
  const dispatcher = opts.dispatcher ?? undici.getGlobalDispatcher()
168
138
 
169
- return new Promise((resolve) => {
170
- let dispatch = (opts, handler) => dispatcher.dispatch(opts, handler)
171
-
139
+ let dispatch = dispatcherCache.get(dispatcher)
140
+ if (dispatch == null) {
141
+ dispatch = (opts, handler) => dispatcher.dispatch(opts, handler)
172
142
  dispatch = dispatchers.catch(dispatch)
173
143
  dispatch = dispatchers.abort(dispatch)
174
144
  dispatch = dispatchers.log(dispatch)
175
- dispatch = opts.upgrade ? dispatch : dispatchers.responseRetry(dispatch)
176
- dispatch = opts.upgrade ? dispatch : dispatchers.responseStatusRetry(dispatch)
177
- dispatch = opts.upgrade ? dispatch : dispatchers.responseBodyRetry(dispatch)
178
- dispatch = opts.upgrade ? dispatch : dispatchers.content(dispatch)
145
+ dispatch = dispatchers.requestId(dispatch)
146
+ dispatch = dispatchers.responseRetry(dispatch)
147
+ dispatch = dispatchers.responseStatusRetry(dispatch)
148
+ dispatch = dispatchers.responseBodyRetry(dispatch)
149
+ dispatch = dispatchers.content(dispatch)
179
150
  dispatch = dispatchers.redirect(dispatch)
180
151
  dispatch = dispatchers.signal(dispatch)
181
- dispatch = opts.upgrade ? dispatch : dispatchers.cache(dispatch)
152
+ dispatch = dispatchers.cache(dispatch)
182
153
  dispatch = dispatchers.proxy(dispatch)
154
+ dispatcherCache.set(dispatcher, dispatch)
155
+ }
183
156
 
184
- dispatch(opts, {
185
- resolve,
186
- logger: opts.logger,
187
- /** @type {Function | null} */ abort: null,
188
- /** @type {stream.Readable | null} */ body: null,
189
- onConnect(abort) {
190
- this.abort = abort
191
- },
192
- onUpgrade(statusCode, rawHeaders, socket) {
193
- const headers = parseHeaders(rawHeaders)
194
-
195
- if (statusCode !== 101) {
196
- this.abort(createError(statusCode, { headers }))
197
- } else {
198
- this.resolve({ headers, socket })
199
- }
200
- },
201
- onHeaders(statusCode, rawHeaders, resume, statusMessage) {
202
- assert(this.abort)
203
-
204
- const headers = parseHeaders(rawHeaders)
205
-
206
- if (statusCode >= 400) {
207
- this.abort(createError(statusCode, { headers }))
208
- } else {
209
- assert(statusCode >= 200)
210
-
211
- const contentLength = Number(headers['content-length'] ?? headers['Content-Length'])
212
-
213
- this.body = new Readable({
214
- read: resume,
215
- highWaterMark: 128 * 1024,
216
- statusCode,
217
- statusMessage,
218
- headers,
219
- size: Number.isFinite(contentLength) ? contentLength : null,
220
- }).on('error', (err) => {
221
- if (this.logger && this.body?.listenerCount('error') === 1) {
222
- this.logger.error({ err }, 'unhandled response body error')
223
- }
224
- })
225
-
226
- this.resolve(this.body)
227
- this.resolve = null
228
- }
229
-
230
- return false
157
+ return new Promise((resolve, reject) =>
158
+ dispatch(
159
+ {
160
+ id: opts.id,
161
+ url,
162
+ method,
163
+ body: opts.body,
164
+ headers,
165
+ origin: url.origin,
166
+ path: url.path ?? (url.search ? `${url.pathname}${url.search ?? ''}` : url.pathname),
167
+ query: opts.query,
168
+ reset: opts.reset ?? false,
169
+ headersTimeout: opts.headersTimeout,
170
+ bodyTimeout: opts.bodyTimeout,
171
+ idempotent,
172
+ signal: opts.signal,
173
+ retry: opts.retry ?? 8,
174
+ proxy: opts.proxy,
175
+ cache: opts.cache,
176
+ upgrade: opts.upgrade,
177
+ follow: opts.follow ?? 3,
178
+ logger: opts.logger,
231
179
  },
232
- onData(chunk) {
233
- assert(this.body)
234
- return this.body.push(chunk)
235
- },
236
- onComplete() {
237
- assert(this.body)
238
- this.body.push(null)
239
- },
240
- onError(err) {
241
- if (this.body) {
242
- this.body.destroy(err)
243
- } else {
244
- this.resolve(Promise.reject(err))
245
- }
180
+ {
181
+ resolve,
182
+ reject,
183
+ logger: opts.logger,
184
+ /** @type {Function | null} */ abort: null,
185
+ /** @type {stream.Readable | null} */ body: null,
186
+ onConnect(abort) {
187
+ this.abort = abort
188
+ },
189
+ onUpgrade(statusCode, rawHeaders, socket) {
190
+ const headers = parseHeaders(rawHeaders)
191
+
192
+ if (statusCode !== 101) {
193
+ this.abort(createError(statusCode, { headers }))
194
+ } else {
195
+ this.resolve({ headers, socket })
196
+ }
197
+ },
198
+ onBodySent(chunk) {},
199
+ onHeaders(statusCode, rawHeaders, resume, statusMessage) {
200
+ assert(this.abort)
201
+
202
+ const headers = parseHeaders(rawHeaders)
203
+
204
+ if (statusCode >= 400) {
205
+ this.abort(createError(statusCode, { headers }))
206
+ } else {
207
+ assert(statusCode >= 200)
208
+
209
+ const contentLength = Number(headers['content-length'] ?? headers['Content-Length'])
210
+
211
+ this.body = new Readable({
212
+ read: resume,
213
+ highWaterMark: 128 * 1024,
214
+ statusCode,
215
+ statusMessage,
216
+ headers,
217
+ size: Number.isFinite(contentLength) ? contentLength : null,
218
+ }).on('error', (err) => {
219
+ if (this.logger && this.body?.listenerCount('error') === 1) {
220
+ this.logger.error({ err }, 'unhandled response body error')
221
+ }
222
+ })
223
+
224
+ this.resolve(this.body)
225
+ this.resolve = null
226
+ }
227
+
228
+ return false
229
+ },
230
+ onData(chunk) {
231
+ assert(this.body)
232
+ return this.body.push(chunk)
233
+ },
234
+ onComplete() {
235
+ assert(this.body)
236
+ this.body.push(null)
237
+ },
238
+ onError(err) {
239
+ if (this.body) {
240
+ this.body.destroy(err)
241
+ } else {
242
+ this.reject(err)
243
+ this.reject = null
244
+ }
245
+ },
246
246
  },
247
- })
248
- })
247
+ ),
248
+ )
249
249
  }
250
250
 
251
251
  module.exports = { request }
@@ -1,6 +1,5 @@
1
1
  const crypto = require('node:crypto')
2
2
  const stream = require('node:stream')
3
- const assert = require('node:assert')
4
3
  const { findHeader, isStream } = require('../utils')
5
4
 
6
5
  class Handler {
@@ -8,7 +7,6 @@ class Handler {
8
7
  this.handler = handler
9
8
  this.md5 = null
10
9
  this.length = null
11
-
12
10
  this.hasher = null
13
11
  this.pos = 0
14
12
  }
@@ -28,9 +26,7 @@ class Handler {
28
26
  onHeaders(statusCode, rawHeaders, resume, statusMessage) {
29
27
  this.md5 = findHeader(rawHeaders, 'content-md5')
30
28
  this.length = findHeader(rawHeaders, 'content-length')
31
-
32
29
  this.hasher = this.md5 != null ? crypto.createHash('md5') : null
33
-
34
30
  return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage)
35
31
  }
36
32
 
@@ -67,6 +63,11 @@ class Handler {
67
63
  }
68
64
 
69
65
  module.exports = (dispatch) => (opts, handler) => {
66
+ if (opts.upgrade) {
67
+ return dispatch(opts, handler)
68
+ }
69
+
70
+ // TODO (fix): case-insensitive check?
70
71
  const md5 = opts.headers?.['content-md5'] ?? opts.headers?.['Content-MD5']
71
72
  const length = opts.headers?.['content-lenght'] ?? opts.headers?.['Content-Length']
72
73
 
@@ -86,7 +87,7 @@ module.exports = (dispatch) => (opts, handler) => {
86
87
  transform(chunk, encoding, callback) {
87
88
  pos += chunk.length
88
89
  hasher?.update(chunk)
89
- callback(null)
90
+ callback(null, chunk)
90
91
  },
91
92
  final(callback) {
92
93
  const hash = hasher?.digest('base64')
@@ -133,7 +134,7 @@ module.exports = (dispatch) => (opts, handler) => {
133
134
  })
134
135
  }
135
136
  } else {
136
- assert(false, 'not implemented')
137
+ throw new Error('not implemented')
137
138
  }
138
139
 
139
140
  return dispatch(opts, new Handler(opts, { handler }))
@@ -1,26 +1,13 @@
1
1
  const { parseHeaders } = require('../utils')
2
2
  const { performance } = require('perf_hooks')
3
3
 
4
- // https://github.com/fastify/fastify/blob/main/lib/reqIdGenFactory.js
5
- // 2,147,483,647 (2^31 − 1) stands for max SMI value (an internal optimization of V8).
6
- // With this upper bound, if you'll be generating 1k ids/sec, you're going to hit it in ~25 days.
7
- // This is very likely to happen in real-world applications, hence the limit is enforced.
8
- // Growing beyond this value will make the id generation slower and cause a deopt.
9
- // In the worst cases, it will become a float, losing accuracy.
10
- const maxInt = 2147483647
11
- let nextReqId = Math.floor(Math.random() * maxInt)
12
- function genReqId() {
13
- nextReqId = (nextReqId + 1) & maxInt
14
- return `req-${nextReqId.toString(36)}`
15
- }
16
-
17
4
  class Handler {
18
5
  constructor(opts, { handler }) {
19
6
  this.handler = handler
20
- this.opts = opts.id ? opts : { ...opts, id: genReqId() }
7
+ this.opts = opts
21
8
  this.abort = null
22
9
  this.aborted = false
23
- this.logger = opts.logger.child({ ureq: { id: opts.id } })
10
+ this.logger = opts.logger.child({ ureq: opts })
24
11
  this.pos = 0
25
12
  this.startTime = 0
26
13
  }
@@ -28,7 +15,7 @@ class Handler {
28
15
  onConnect(abort) {
29
16
  this.abort = abort
30
17
  this.startTime = performance.now()
31
- this.logger.debug({ ureq: this.opts }, 'upstream request started')
18
+ this.logger.debug('upstream request started')
32
19
  this.handler.onConnect((reason) => {
33
20
  this.aborted = true
34
21
  this.abort(reason)
@@ -36,9 +23,9 @@ class Handler {
36
23
  }
37
24
 
38
25
  onUpgrade(statusCode, rawHeaders, socket) {
39
- this.logger.debug({ ureq: this.opts }, 'upstream request upgraded')
26
+ this.logger.debug('upstream request upgraded')
40
27
  socket.on('close', () => {
41
- this.logger.debug({ ureq: this.opts }, 'upstream request socket closed')
28
+ this.logger.debug('upstream request socket closed')
42
29
  })
43
30
  return this.handler.onUpgrade(statusCode, rawHeaders, socket)
44
31
  }
@@ -102,7 +102,7 @@ const HOP_EXPR =
102
102
  function forEachHeader(headers, fn) {
103
103
  if (Array.isArray(headers)) {
104
104
  for (let n = 0; n < headers.length; n += 2) {
105
- fn(headers[n + 0].toString(), headers[n + 1].toString())
105
+ fn(headers[n + 0], headers[n + 1])
106
106
  }
107
107
  } else {
108
108
  for (const [key, val] of Object.entries(headers)) {
@@ -123,16 +123,16 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
123
123
 
124
124
  forEachHeader(headers, (key, val) => {
125
125
  const len = key.length
126
- if (len === 3 && !via && key.toLowerCase() === 'via') {
127
- via = val
128
- } else if (len === 4 && !host && key.toLowerCase() === 'host') {
129
- host = val
130
- } else if (len === 9 && !forwarded && key.toLowerCase() === 'forwarded') {
131
- forwarded = val
132
- } else if (len === 10 && !connection && key.toLowerCase() === 'connection') {
133
- connection = val
134
- } else if (len === 10 && !authority && key.toLowerCase() === ':authority') {
135
- authority = val
126
+ if (len === 3 && !via && key.toString().toLowerCase() === 'via') {
127
+ via = val.toString()
128
+ } else if (len === 4 && !host && key.toString().toLowerCase() === 'host') {
129
+ host = val.toString()
130
+ } else if (len === 9 && !forwarded && key.toString().toLowerCase() === 'forwarded') {
131
+ forwarded = val.toString()
132
+ } else if (len === 10 && !connection && key.toString().toLowerCase() === 'connection') {
133
+ connection = val.toString()
134
+ } else if (len === 10 && !authority && key.toString().toLowerCase() === ':authority') {
135
+ authority = val.toString()
136
136
  }
137
137
  })
138
138
 
@@ -142,8 +142,10 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
142
142
  }
143
143
 
144
144
  forEachHeader(headers, (key, val) => {
145
+ key = key.toString()
146
+
145
147
  if (key.charAt(0) !== ':' && !remove.includes(key) && !HOP_EXPR.test(key)) {
146
- acc = fn(acc, key, val)
148
+ acc = fn(acc, key, val.toString())
147
149
  }
148
150
  })
149
151
 
@@ -34,11 +34,15 @@ class Handler {
34
34
  }
35
35
 
36
36
  onUpgrade(statusCode, headers, socket) {
37
- this.handler.onUpgrade(statusCode, headers, socket)
37
+ return this.handler.onUpgrade(statusCode, headers, socket)
38
+ }
39
+
40
+ onBodySent(chunk) {
41
+ return this.handler.onBodySent(chunk)
38
42
  }
39
43
 
40
44
  onError(error) {
41
- this.handler.onError(error)
45
+ return this.handler.onError(error)
42
46
  }
43
47
 
44
48
  onHeaders(statusCode, headers, resume, statusText) {
@@ -53,7 +57,7 @@ class Handler {
53
57
  this.location = findHeader(headers, 'location')
54
58
 
55
59
  if (!this.location) {
56
- throw new Error(`Missing redirection location.`)
60
+ throw new Error(`Missing redirection location .`)
57
61
  }
58
62
 
59
63
  this.count += 1
@@ -64,7 +68,6 @@ class Handler {
64
68
  }
65
69
  } else {
66
70
  const maxCount = this.opts.follow.count ?? 0
67
-
68
71
  if (this.count >= maxCount) {
69
72
  throw new Error(`Max redirections reached: ${maxCount}.`)
70
73
  }
@@ -136,13 +139,7 @@ class Handler {
136
139
 
137
140
  this.dispatch(this.opts, this)
138
141
  } else {
139
- this.handler.onComplete(trailers)
140
- }
141
- }
142
-
143
- onBodySent(chunk) {
144
- if (this.handler.onBodySent) {
145
- this.handler.onBodySent(chunk)
142
+ return this.handler.onComplete(trailers)
146
143
  }
147
144
  }
148
145
  }
@@ -0,0 +1,33 @@
1
+ // https://github.com/fastify/fastify/blob/main/lib/reqIdGenFactory.js
2
+ // 2,147,483,647 (2^31 − 1) stands for max SMI value (an internal optimization of V8).
3
+ // With this upper bound, if you'll be generating 1k ids/sec, you're going to hit it in ~25 days.
4
+ // This is very likely to happen in real-world applications, hence the limit is enforced.
5
+ // Growing beyond this value will make the id generation slower and cause a deopt.
6
+ // In the worst cases, it will become a float, losing accuracy.
7
+ const maxInt = 2147483647
8
+ let nextReqId = Math.floor(Math.random() * maxInt)
9
+ function genReqId() {
10
+ nextReqId = (nextReqId + 1) & maxInt
11
+ return `req-${nextReqId.toString(36)}`
12
+ }
13
+
14
+ module.exports = (dispatch) => (opts, handler) => {
15
+ if (opts.id === null) {
16
+ return dispatch(opts, handler)
17
+ }
18
+
19
+ let id = opts.id ?? opts.headers?.['request-id'] ?? opts.headers?.['Request-Id']
20
+ id = id ? `${id},${genReqId()}` : genReqId()
21
+
22
+ return dispatch(
23
+ {
24
+ ...opts,
25
+ id,
26
+ headers: {
27
+ ...opts.headers,
28
+ 'request-id': id,
29
+ },
30
+ },
31
+ handler,
32
+ )
33
+ }
@@ -83,6 +83,6 @@ class Handler {
83
83
  }
84
84
 
85
85
  module.exports = (dispatch) => (opts, handler) =>
86
- opts.idempotent && opts.retry
86
+ opts.idempotent && opts.retry && !opts.upgrade
87
87
  ? dispatch(opts, new Handler(opts, { handler, dispatch }))
88
88
  : dispatch(opts, handler)
@@ -34,12 +34,16 @@ class Handler {
34
34
  }
35
35
 
36
36
  onComplete(rawTrailers) {
37
- this.signal.removeEventListener('abort', this.abort)
37
+ if (this.abort) {
38
+ this.signal.removeEventListener('abort', this.abort)
39
+ }
38
40
  return this.handler.onComplete(rawTrailers)
39
41
  }
40
42
 
41
43
  onError(err) {
42
- this.signal.removeEventListener('abort', this.abort)
44
+ if (this.abort) {
45
+ this.signal.removeEventListener('abort', this.abort)
46
+ }
43
47
  return this.handler.onError(err)
44
48
  }
45
49
  }
package/lib/utils.js CHANGED
@@ -204,9 +204,43 @@ function isStream(obj) {
204
204
  )
205
205
  }
206
206
 
207
+ // based on https://github.com/node-fetch/fetch-blob/blob/8ab587d34080de94140b54f07168451e7d0b655e/index.js#L229-L241 (MIT License)
208
+ function isBlobLike(object) {
209
+ return (
210
+ (Blob && object instanceof Blob) ||
211
+ (object &&
212
+ typeof object === 'object' &&
213
+ (typeof object.stream === 'function' || typeof object.arrayBuffer === 'function') &&
214
+ /^(Blob|File)$/.test(object[Symbol.toStringTag]))
215
+ )
216
+ }
217
+
218
+ function isBuffer(buffer) {
219
+ // See, https://github.com/mcollina/undici/pull/319
220
+ return buffer instanceof Uint8Array || Buffer.isBuffer(buffer)
221
+ }
222
+
223
+ function bodyLength(body) {
224
+ if (body == null) {
225
+ return 0
226
+ } else if (isStream(body)) {
227
+ const state = body._readableState
228
+ return state && state.ended === true && Number.isFinite(state.length) ? state.length : null
229
+ } else if (isBlobLike(body)) {
230
+ return body.size != null ? body.size : null
231
+ } else if (isBuffer(body)) {
232
+ return body.byteLength
233
+ }
234
+
235
+ return null
236
+ }
237
+
207
238
  module.exports = {
208
239
  isStream,
240
+ isBuffer,
241
+ isBlobLike,
209
242
  AbortError,
243
+ bodyLength,
210
244
  parseHeaders,
211
245
  isDisturbed,
212
246
  parseContentRange,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",
@@ -18,6 +18,9 @@
18
18
  "eslint": "^8.50.0",
19
19
  "eslint-config-prettier": "^9.0.0",
20
20
  "eslint-config-standard": "^17.0.0",
21
+ "eslint-plugin-import": "^2.28.1",
22
+ "eslint-plugin-n": "^16.2.0",
23
+ "eslint-plugin-promise": "^6.1.1",
21
24
  "husky": "^8.0.3",
22
25
  "lint-staged": "^14.0.1",
23
26
  "pinst": "^3.0.0",