undici 6.19.8 → 6.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,6 +84,7 @@ The `body` mixins are the most common way to format the request/response body. M
84
84
 
85
85
  - [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer)
86
86
  - [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob)
87
+ - [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes)
87
88
  - [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json)
88
89
  - [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text)
89
90
 
@@ -488,11 +488,13 @@ The `RequestOptions.method` property should not be value `'CONNECT'`.
488
488
 
489
489
  `body` contains the following additional [body mixin](https://fetch.spec.whatwg.org/#body-mixin) methods and properties:
490
490
 
491
- - `text()`
492
- - `json()`
493
- - `arrayBuffer()`
494
- - `body`
495
- - `bodyUsed`
491
+ * [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer)
492
+ * [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob)
493
+ * [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes)
494
+ * [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json)
495
+ * [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text)
496
+ * `body`
497
+ * `bodyUsed`
496
498
 
497
499
  `body` can not be consumed twice. For example, calling `text()` after `json()` throws `TypeError`.
498
500
 
@@ -984,6 +986,203 @@ client.dispatch(
984
986
  );
985
987
  ```
986
988
 
989
+ ##### `Response Error Interceptor`
990
+
991
+ **Introduction**
992
+
993
+ The Response Error Interceptor is designed to handle HTTP response errors efficiently. It intercepts responses and throws detailed errors for responses with status codes indicating failure (4xx, 5xx). This interceptor enhances error handling by providing structured error information, including response headers, data, and status codes.
994
+
995
+ **ResponseError Class**
996
+
997
+ The `ResponseError` class extends the `UndiciError` class and encapsulates detailed error information. It captures the response status code, headers, and data, providing a structured way to handle errors.
998
+
999
+ **Definition**
1000
+
1001
+ ```js
1002
+ class ResponseError extends UndiciError {
1003
+ constructor (message, code, { headers, data }) {
1004
+ super(message);
1005
+ this.name = 'ResponseError';
1006
+ this.message = message || 'Response error';
1007
+ this.code = 'UND_ERR_RESPONSE';
1008
+ this.statusCode = code;
1009
+ this.data = data;
1010
+ this.headers = headers;
1011
+ }
1012
+ }
1013
+ ```
1014
+
1015
+ **Interceptor Handler**
1016
+
1017
+ The interceptor's handler class extends `DecoratorHandler` and overrides methods to capture response details and handle errors based on the response status code.
1018
+
1019
+ **Methods**
1020
+
1021
+ - **onConnect**: Initializes response properties.
1022
+ - **onHeaders**: Captures headers and status code. Decodes body if content type is `application/json` or `text/plain`.
1023
+ - **onData**: Appends chunks to the body if status code indicates an error.
1024
+ - **onComplete**: Finalizes error handling, constructs a `ResponseError`, and invokes the `onError` method.
1025
+ - **onError**: Propagates errors to the handler.
1026
+
1027
+ **Definition**
1028
+
1029
+ ```js
1030
+ class Handler extends DecoratorHandler {
1031
+ // Private properties
1032
+ #handler;
1033
+ #statusCode;
1034
+ #contentType;
1035
+ #decoder;
1036
+ #headers;
1037
+ #body;
1038
+
1039
+ constructor (opts, { handler }) {
1040
+ super(handler);
1041
+ this.#handler = handler;
1042
+ }
1043
+
1044
+ onConnect (abort) {
1045
+ this.#statusCode = 0;
1046
+ this.#contentType = null;
1047
+ this.#decoder = null;
1048
+ this.#headers = null;
1049
+ this.#body = '';
1050
+ return this.#handler.onConnect(abort);
1051
+ }
1052
+
1053
+ onHeaders (statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
1054
+ this.#statusCode = statusCode;
1055
+ this.#headers = headers;
1056
+ this.#contentType = headers['content-type'];
1057
+
1058
+ if (this.#statusCode < 400) {
1059
+ return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers);
1060
+ }
1061
+
1062
+ if (this.#contentType === 'application/json' || this.#contentType === 'text/plain') {
1063
+ this.#decoder = new TextDecoder('utf-8');
1064
+ }
1065
+ }
1066
+
1067
+ onData (chunk) {
1068
+ if (this.#statusCode < 400) {
1069
+ return this.#handler.onData(chunk);
1070
+ }
1071
+ this.#body += this.#decoder?.decode(chunk, { stream: true }) ?? '';
1072
+ }
1073
+
1074
+ onComplete (rawTrailers) {
1075
+ if (this.#statusCode >= 400) {
1076
+ this.#body += this.#decoder?.decode(undefined, { stream: false }) ?? '';
1077
+ if (this.#contentType === 'application/json') {
1078
+ try {
1079
+ this.#body = JSON.parse(this.#body);
1080
+ } catch {
1081
+ // Do nothing...
1082
+ }
1083
+ }
1084
+
1085
+ let err;
1086
+ const stackTraceLimit = Error.stackTraceLimit;
1087
+ Error.stackTraceLimit = 0;
1088
+ try {
1089
+ err = new ResponseError('Response Error', this.#statusCode, this.#headers, this.#body);
1090
+ } finally {
1091
+ Error.stackTraceLimit = stackTraceLimit;
1092
+ }
1093
+
1094
+ this.#handler.onError(err);
1095
+ } else {
1096
+ this.#handler.onComplete(rawTrailers);
1097
+ }
1098
+ }
1099
+
1100
+ onError (err) {
1101
+ this.#handler.onError(err);
1102
+ }
1103
+ }
1104
+
1105
+ module.exports = (dispatch) => (opts, handler) => opts.throwOnError
1106
+ ? dispatch(opts, new Handler(opts, { handler }))
1107
+ : dispatch(opts, handler);
1108
+ ```
1109
+
1110
+ **Tests**
1111
+
1112
+ Unit tests ensure the interceptor functions correctly, handling both error and non-error responses appropriately.
1113
+
1114
+ **Example Tests**
1115
+
1116
+ - **No Error if `throwOnError` is False**:
1117
+
1118
+ ```js
1119
+ test('should not error if request is not meant to throw error', async (t) => {
1120
+ const opts = { throwOnError: false };
1121
+ const handler = { onError: () => {}, onData: () => {}, onComplete: () => {} };
1122
+ const interceptor = createResponseErrorInterceptor((opts, handler) => handler.onComplete());
1123
+ assert.doesNotThrow(() => interceptor(opts, handler));
1124
+ });
1125
+ ```
1126
+
1127
+ - **Error if Status Code is in Specified Error Codes**:
1128
+
1129
+ ```js
1130
+ test('should error if request status code is in the specified error codes', async (t) => {
1131
+ const opts = { throwOnError: true, statusCodes: [500] };
1132
+ const response = { statusCode: 500 };
1133
+ let capturedError;
1134
+ const handler = {
1135
+ onError: (err) => { capturedError = err; },
1136
+ onData: () => {},
1137
+ onComplete: () => {}
1138
+ };
1139
+
1140
+ const interceptor = createResponseErrorInterceptor((opts, handler) => {
1141
+ if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
1142
+ handler.onError(new Error('Response Error'));
1143
+ } else {
1144
+ handler.onComplete();
1145
+ }
1146
+ });
1147
+
1148
+ interceptor({ ...opts, response }, handler);
1149
+
1150
+ await new Promise(resolve => setImmediate(resolve));
1151
+
1152
+ assert(capturedError, 'Expected error to be captured but it was not.');
1153
+ assert.strictEqual(capturedError.message, 'Response Error');
1154
+ assert.strictEqual(response.statusCode, 500);
1155
+ });
1156
+ ```
1157
+
1158
+ - **No Error if Status Code is Not in Specified Error Codes**:
1159
+
1160
+ ```js
1161
+ test('should not error if request status code is not in the specified error codes', async (t) => {
1162
+ const opts = { throwOnError: true, statusCodes: [500] };
1163
+ const response = { statusCode: 404 };
1164
+ const handler = {
1165
+ onError: () => {},
1166
+ onData: () => {},
1167
+ onComplete: () => {}
1168
+ };
1169
+
1170
+ const interceptor = createResponseErrorInterceptor((opts, handler) => {
1171
+ if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
1172
+ handler.onError(new Error('Response Error'));
1173
+ } else {
1174
+ handler.onComplete();
1175
+ }
1176
+ });
1177
+
1178
+ assert.doesNotThrow(() => interceptor({ ...opts, response }, handler));
1179
+ });
1180
+ ```
1181
+
1182
+ **Conclusion**
1183
+
1184
+ The Response Error Interceptor provides a robust mechanism for handling HTTP response errors by capturing detailed error information and propagating it through a structured `ResponseError` class. This enhancement improves error handling and debugging capabilities in applications using the interceptor.
1185
+
987
1186
  ## Instance Events
988
1187
 
989
1188
  ### Event: `'connect'`
@@ -28,6 +28,7 @@ This API is implemented as per the standard, you can find documentation on [MDN]
28
28
 
29
29
  - [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer)
30
30
  - [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob)
31
+ - [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes)
31
32
  - [`.formData()`](https://fetch.spec.whatwg.org/#dom-body-formdata)
32
33
  - [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json)
33
34
  - [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text)
@@ -19,7 +19,7 @@ Extends: [`Dispatch.DispatchOptions`](Dispatcher.md#parameter-dispatchoptions).
19
19
 
20
20
  #### `RetryOptions`
21
21
 
22
- - **retry** `(err: Error, context: RetryContext, callback: (err?: Error | null) => void) => void` (optional) - Function to be called after every retry. It should pass error if no more retries should be performed.
22
+ - **retry** `(err: Error, context: RetryContext, callback: (err?: Error | null) => void) => number | null` (optional) - Function to be called after every retry. It should pass error if no more retries should be performed.
23
23
  - **maxRetries** `number` (optional) - Maximum number of retries. Default: `5`
24
24
  - **maxTimeout** `number` (optional) - Maximum number of milliseconds to wait before retrying. Default: `30000` (30 seconds)
25
25
  - **minTimeout** `number` (optional) - Minimum number of milliseconds to wait before retrying. Default: `500` (half a second)
@@ -50,9 +50,9 @@ class UpgradeHandler extends AsyncResource {
50
50
  }
51
51
 
52
52
  onUpgrade (statusCode, rawHeaders, socket) {
53
- const { callback, opaque, context } = this
53
+ assert(statusCode === 101)
54
54
 
55
- assert.strictEqual(statusCode, 101)
55
+ const { callback, opaque, context } = this
56
56
 
57
57
  removeSignal(this)
58
58
 
@@ -121,6 +121,11 @@ class BodyReadable extends Readable {
121
121
  return consume(this, 'blob')
122
122
  }
123
123
 
124
+ // https://fetch.spec.whatwg.org/#dom-body-bytes
125
+ async bytes () {
126
+ return consume(this, 'bytes')
127
+ }
128
+
124
129
  // https://fetch.spec.whatwg.org/#dom-body-arraybuffer
125
130
  async arrayBuffer () {
126
131
  return consume(this, 'arrayBuffer')
@@ -306,6 +311,31 @@ function chunksDecode (chunks, length) {
306
311
  return buffer.utf8Slice(start, bufferLength)
307
312
  }
308
313
 
314
+ /**
315
+ * @param {Buffer[]} chunks
316
+ * @param {number} length
317
+ * @returns {Uint8Array}
318
+ */
319
+ function chunksConcat (chunks, length) {
320
+ if (chunks.length === 0 || length === 0) {
321
+ return new Uint8Array(0)
322
+ }
323
+ if (chunks.length === 1) {
324
+ // fast-path
325
+ return new Uint8Array(chunks[0])
326
+ }
327
+ const buffer = new Uint8Array(Buffer.allocUnsafeSlow(length).buffer)
328
+
329
+ let offset = 0
330
+ for (let i = 0; i < chunks.length; ++i) {
331
+ const chunk = chunks[i]
332
+ buffer.set(chunk, offset)
333
+ offset += chunk.length
334
+ }
335
+
336
+ return buffer
337
+ }
338
+
309
339
  function consumeEnd (consume) {
310
340
  const { type, body, resolve, stream, length } = consume
311
341
 
@@ -315,17 +345,11 @@ function consumeEnd (consume) {
315
345
  } else if (type === 'json') {
316
346
  resolve(JSON.parse(chunksDecode(body, length)))
317
347
  } else if (type === 'arrayBuffer') {
318
- const dst = new Uint8Array(length)
319
-
320
- let pos = 0
321
- for (const buf of body) {
322
- dst.set(buf, pos)
323
- pos += buf.byteLength
324
- }
325
-
326
- resolve(dst.buffer)
348
+ resolve(chunksConcat(body, length).buffer)
327
349
  } else if (type === 'blob') {
328
350
  resolve(new Blob(body, { type: stream[kContentType] }))
351
+ } else if (type === 'bytes') {
352
+ resolve(chunksConcat(body, length))
329
353
  }
330
354
 
331
355
  consumeFinish(consume)
@@ -4,6 +4,9 @@ const net = require('node:net')
4
4
  const assert = require('node:assert')
5
5
  const util = require('./util')
6
6
  const { InvalidArgumentError, ConnectTimeoutError } = require('./errors')
7
+ const timers = require('../util/timers')
8
+
9
+ function noop () {}
7
10
 
8
11
  let tls // include tls conditionally since it is not always available
9
12
 
@@ -91,9 +94,11 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
91
94
  servername = servername || options.servername || util.getServerName(host) || null
92
95
 
93
96
  const sessionKey = servername || hostname
97
+ assert(sessionKey)
98
+
94
99
  const session = customSession || sessionCache.get(sessionKey) || null
95
100
 
96
- assert(sessionKey)
101
+ port = port || 443
97
102
 
98
103
  socket = tls.connect({
99
104
  highWaterMark: 16384, // TLS in node can't have bigger HWM anyway...
@@ -104,7 +109,7 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
104
109
  // TODO(HTTP/2): Add support for h2c
105
110
  ALPNProtocols: allowH2 ? ['http/1.1', 'h2'] : ['http/1.1'],
106
111
  socket: httpSocket, // upgrade socket connection
107
- port: port || 443,
112
+ port,
108
113
  host: hostname
109
114
  })
110
115
 
@@ -115,11 +120,14 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
115
120
  })
116
121
  } else {
117
122
  assert(!httpSocket, 'httpSocket can only be sent on TLS update')
123
+
124
+ port = port || 80
125
+
118
126
  socket = net.connect({
119
127
  highWaterMark: 64 * 1024, // Same as nodejs fs streams.
120
128
  ...options,
121
129
  localAddress,
122
- port: port || 80,
130
+ port,
123
131
  host: hostname
124
132
  })
125
133
  }
@@ -130,12 +138,12 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
130
138
  socket.setKeepAlive(true, keepAliveInitialDelay)
131
139
  }
132
140
 
133
- const cancelTimeout = setupTimeout(() => onConnectTimeout(socket), timeout)
141
+ const clearConnectTimeout = setupConnectTimeout(new WeakRef(socket), { timeout, hostname, port })
134
142
 
135
143
  socket
136
144
  .setNoDelay(true)
137
145
  .once(protocol === 'https:' ? 'secureConnect' : 'connect', function () {
138
- cancelTimeout()
146
+ queueMicrotask(clearConnectTimeout)
139
147
 
140
148
  if (callback) {
141
149
  const cb = callback
@@ -144,7 +152,7 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
144
152
  }
145
153
  })
146
154
  .on('error', function (err) {
147
- cancelTimeout()
155
+ queueMicrotask(clearConnectTimeout)
148
156
 
149
157
  if (callback) {
150
158
  const cb = callback
@@ -157,36 +165,70 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
157
165
  }
158
166
  }
159
167
 
160
- function setupTimeout (onConnectTimeout, timeout) {
161
- if (!timeout) {
162
- return () => {}
163
- }
168
+ /**
169
+ * @param {WeakRef<net.Socket>} socketWeakRef
170
+ * @param {object} opts
171
+ * @param {number} opts.timeout
172
+ * @param {string} opts.hostname
173
+ * @param {number} opts.port
174
+ * @returns {() => void}
175
+ */
176
+ const setupConnectTimeout = process.platform === 'win32'
177
+ ? (socketWeakRef, opts) => {
178
+ if (!opts.timeout) {
179
+ return noop
180
+ }
164
181
 
165
- let s1 = null
166
- let s2 = null
167
- const timeoutId = setTimeout(() => {
168
- // setImmediate is added to make sure that we prioritize socket error events over timeouts
169
- s1 = setImmediate(() => {
170
- if (process.platform === 'win32') {
182
+ let s1 = null
183
+ let s2 = null
184
+ const fastTimer = timers.setFastTimeout(() => {
185
+ // setImmediate is added to make sure that we prioritize socket error events over timeouts
186
+ s1 = setImmediate(() => {
171
187
  // Windows needs an extra setImmediate probably due to implementation differences in the socket logic
172
- s2 = setImmediate(() => onConnectTimeout())
173
- } else {
174
- onConnectTimeout()
188
+ s2 = setImmediate(() => onConnectTimeout(socketWeakRef.deref(), opts))
189
+ })
190
+ }, opts.timeout)
191
+ return () => {
192
+ timers.clearFastTimeout(fastTimer)
193
+ clearImmediate(s1)
194
+ clearImmediate(s2)
195
+ }
196
+ }
197
+ : (socketWeakRef, opts) => {
198
+ if (!opts.timeout) {
199
+ return noop
175
200
  }
176
- })
177
- }, timeout)
178
- return () => {
179
- clearTimeout(timeoutId)
180
- clearImmediate(s1)
181
- clearImmediate(s2)
182
- }
183
- }
184
201
 
185
- function onConnectTimeout (socket) {
202
+ let s1 = null
203
+ const fastTimer = timers.setFastTimeout(() => {
204
+ // setImmediate is added to make sure that we prioritize socket error events over timeouts
205
+ s1 = setImmediate(() => {
206
+ onConnectTimeout(socketWeakRef.deref(), opts)
207
+ })
208
+ }, opts.timeout)
209
+ return () => {
210
+ timers.clearFastTimeout(fastTimer)
211
+ clearImmediate(s1)
212
+ }
213
+ }
214
+
215
+ /**
216
+ * @param {net.Socket} socket
217
+ * @param {object} opts
218
+ * @param {number} opts.timeout
219
+ * @param {string} opts.hostname
220
+ * @param {number} opts.port
221
+ */
222
+ function onConnectTimeout (socket, opts) {
186
223
  let message = 'Connect Timeout Error'
187
224
  if (Array.isArray(socket.autoSelectFamilyAttemptedAddresses)) {
188
- message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')})`
225
+ message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')},`
226
+ } else {
227
+ message += ` (attempted address: ${opts.hostname}:${opts.port},`
189
228
  }
229
+
230
+ message += ` timeout: ${opts.timeout}ms)`
231
+
190
232
  util.destroy(socket, new ConnectTimeoutError(message))
191
233
  }
192
234
 
@@ -195,6 +195,18 @@ class RequestRetryError extends UndiciError {
195
195
  }
196
196
  }
197
197
 
198
+ class ResponseError extends UndiciError {
199
+ constructor (message, code, { headers, data }) {
200
+ super(message)
201
+ this.name = 'ResponseError'
202
+ this.message = message || 'Response error'
203
+ this.code = 'UND_ERR_RESPONSE'
204
+ this.statusCode = code
205
+ this.data = data
206
+ this.headers = headers
207
+ }
208
+ }
209
+
198
210
  class SecureProxyConnectionError extends UndiciError {
199
211
  constructor (cause, message, options) {
200
212
  super(message, { cause, ...(options ?? {}) })
@@ -227,5 +239,6 @@ module.exports = {
227
239
  BalancedPoolMissingUpstreamError,
228
240
  ResponseExceededMaxSizeError,
229
241
  RequestRetryError,
242
+ ResponseError,
230
243
  SecureProxyConnectionError
231
244
  }
package/lib/core/util.js CHANGED
@@ -233,7 +233,7 @@ function getServerName (host) {
233
233
  return null
234
234
  }
235
235
 
236
- assert.strictEqual(typeof host, 'string')
236
+ assert(typeof host === 'string')
237
237
 
238
238
  const servername = getHostname(host)
239
239
  if (net.isIP(servername)) {