undici 7.14.0 → 7.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1094,6 +1094,65 @@ await client.request({
1094
1094
  });
1095
1095
  ```
1096
1096
 
1097
+ ##### `decompress`
1098
+
1099
+ ⚠️ The decompress interceptor is experimental and subject to change.
1100
+
1101
+ The `decompress` interceptor automatically decompresses response bodies that are compressed with gzip, deflate, brotli, or zstd compression. It removes the `content-encoding` and `content-length` headers from decompressed responses and supports RFC-9110 compliant multiple encodings.
1102
+
1103
+ **Options**
1104
+
1105
+ - `skipErrorResponses` - Whether to skip decompression for error responses (status codes >= 400). Default: `true`.
1106
+ - `skipStatusCodes` - Array of status codes to skip decompression for. Default: `[204, 304]`.
1107
+
1108
+ **Example - Basic Decompress Interceptor**
1109
+
1110
+ ```js
1111
+ const { Client, interceptors } = require("undici");
1112
+ const { decompress } = interceptors;
1113
+
1114
+ const client = new Client("http://example.com").compose(
1115
+ decompress()
1116
+ );
1117
+
1118
+ // Automatically decompresses gzip/deflate/brotli/zstd responses
1119
+ const response = await client.request({
1120
+ method: "GET",
1121
+ path: "/"
1122
+ });
1123
+ ```
1124
+
1125
+ **Example - Custom Options**
1126
+
1127
+ ```js
1128
+ const { Client, interceptors } = require("undici");
1129
+ const { decompress } = interceptors;
1130
+
1131
+ const client = new Client("http://example.com").compose(
1132
+ decompress({
1133
+ skipErrorResponses: false, // Decompress 5xx responses
1134
+ skipStatusCodes: [204, 304, 201] // Skip these status codes
1135
+ })
1136
+ );
1137
+ ```
1138
+
1139
+ **Supported Encodings**
1140
+
1141
+ - `gzip` / `x-gzip` - GZIP compression
1142
+ - `deflate` / `x-compress` - DEFLATE compression
1143
+ - `br` - Brotli compression
1144
+ - `zstd` - Zstandard compression
1145
+ - Multiple encodings (e.g., `gzip, deflate`) are supported per RFC-9110
1146
+
1147
+ **Behavior**
1148
+
1149
+ - Skips decompression for status codes < 200 or >= 400 (configurable)
1150
+ - Skips decompression for 204 No Content and 304 Not Modified by default
1151
+ - Removes `content-encoding` and `content-length` headers when decompressing
1152
+ - Passes through unsupported encodings unchanged
1153
+ - Handles case-insensitive encoding names
1154
+ - Supports streaming decompression without buffering
1155
+
1097
1156
  ##### `Cache Interceptor`
1098
1157
 
1099
1158
  The `cache` interceptor implements client-side response caching as described in
package/index.js CHANGED
@@ -46,7 +46,8 @@ module.exports.interceptors = {
46
46
  retry: require('./lib/interceptor/retry'),
47
47
  dump: require('./lib/interceptor/dump'),
48
48
  dns: require('./lib/interceptor/dns'),
49
- cache: require('./lib/interceptor/cache')
49
+ cache: require('./lib/interceptor/cache'),
50
+ decompress: require('./lib/interceptor/decompress')
50
51
  }
51
52
 
52
53
  module.exports.cacheStores = {
package/lib/core/util.js CHANGED
@@ -102,13 +102,24 @@ function isBlobLike (object) {
102
102
  }
103
103
  }
104
104
 
105
+ /**
106
+ * @param {string} url The path to check for query strings or fragments.
107
+ * @returns {boolean} Returns true if the path contains a query string or fragment.
108
+ */
109
+ function pathHasQueryOrFragment (url) {
110
+ return (
111
+ url.includes('?') ||
112
+ url.includes('#')
113
+ )
114
+ }
115
+
105
116
  /**
106
117
  * @param {string} url The URL to add the query params to
107
118
  * @param {import('node:querystring').ParsedUrlQueryInput} queryParams The object to serialize into a URL query string
108
119
  * @returns {string} The URL with the query params added
109
120
  */
110
121
  function serializePathWithQuery (url, queryParams) {
111
- if (url.includes('?') || url.includes('#')) {
122
+ if (pathHasQueryOrFragment(url)) {
112
123
  throw new Error('Query params cannot be passed when url already contains "?" or "#".')
113
124
  }
114
125
 
@@ -924,6 +935,7 @@ module.exports = {
924
935
  assertRequestHandler,
925
936
  getSocketInfo,
926
937
  isFormDataLike,
938
+ pathHasQueryOrFragment,
927
939
  serializePathWithQuery,
928
940
  addAbortListener,
929
941
  isValidHTTPToken,
@@ -45,27 +45,14 @@ class Agent extends DispatcherBase {
45
45
  }
46
46
 
47
47
  this[kOnConnect] = (origin, targets) => {
48
- const result = this[kClients].get(origin)
49
- if (result) {
50
- result.count += 1
51
- }
52
48
  this.emit('connect', origin, [this, ...targets])
53
49
  }
54
50
 
55
51
  this[kOnDisconnect] = (origin, targets, err) => {
56
- const result = this[kClients].get(origin)
57
- if (result) {
58
- result.count -= 1
59
- if (result.count <= 0) {
60
- this[kClients].delete(origin)
61
- result.dispatcher.destroy()
62
- }
63
- }
64
52
  this.emit('disconnect', origin, [this, ...targets], err)
65
53
  }
66
54
 
67
55
  this[kOnConnectionError] = (origin, targets, err) => {
68
- // TODO: should this decrement result.count here?
69
56
  this.emit('connectionError', origin, [this, ...targets], err)
70
57
  }
71
58
  }
@@ -89,11 +76,33 @@ class Agent extends DispatcherBase {
89
76
  const result = this[kClients].get(key)
90
77
  let dispatcher = result && result.dispatcher
91
78
  if (!dispatcher) {
79
+ const closeClientIfUnused = (connected) => {
80
+ const result = this[kClients].get(key)
81
+ if (result) {
82
+ if (connected) result.count -= 1
83
+ if (result.count <= 0) {
84
+ this[kClients].delete(key)
85
+ result.dispatcher.close()
86
+ }
87
+ }
88
+ }
92
89
  dispatcher = this[kFactory](opts.origin, this[kOptions])
93
90
  .on('drain', this[kOnDrain])
94
- .on('connect', this[kOnConnect])
95
- .on('disconnect', this[kOnDisconnect])
96
- .on('connectionError', this[kOnConnectionError])
91
+ .on('connect', (origin, targets) => {
92
+ const result = this[kClients].get(key)
93
+ if (result) {
94
+ result.count += 1
95
+ }
96
+ this[kOnConnect](origin, targets)
97
+ })
98
+ .on('disconnect', (origin, targets, err) => {
99
+ closeClientIfUnused(true)
100
+ this[kOnDisconnect](origin, targets, err)
101
+ })
102
+ .on('connectionError', (origin, targets, err) => {
103
+ closeClientIfUnused(false)
104
+ this[kOnConnectionError](origin, targets, err)
105
+ })
97
106
 
98
107
  this[kClients].set(key, { count: 0, dispatcher })
99
108
  }
@@ -66,7 +66,7 @@ function lazyllhttp () {
66
66
  let mod
67
67
  try {
68
68
  mod = new WebAssembly.Module(require('../llhttp/llhttp_simd-wasm.js'))
69
- } catch (e) {
69
+ } catch {
70
70
  /* istanbul ignore next */
71
71
 
72
72
  // We could check if the error was caused by the simd option not
@@ -0,0 +1,253 @@
1
+ 'use strict'
2
+
3
+ const { createInflate, createGunzip, createBrotliDecompress, createZstdDecompress } = require('node:zlib')
4
+ const { pipeline } = require('node:stream')
5
+ const DecoratorHandler = require('../handler/decorator-handler')
6
+
7
+ /** @typedef {import('node:stream').Transform} Transform */
8
+ /** @typedef {import('node:stream').Transform} Controller */
9
+ /** @typedef {Transform&import('node:zlib').Zlib} DecompressorStream */
10
+
11
+ /** @type {Record<string, () => DecompressorStream>} */
12
+ const supportedEncodings = {
13
+ gzip: createGunzip,
14
+ 'x-gzip': createGunzip,
15
+ br: createBrotliDecompress,
16
+ deflate: createInflate,
17
+ compress: createInflate,
18
+ 'x-compress': createInflate,
19
+ ...(createZstdDecompress ? { zstd: createZstdDecompress } : {})
20
+ }
21
+
22
+ const defaultSkipStatusCodes = /** @type {const} */ ([204, 304])
23
+
24
+ let warningEmitted = /** @type {boolean} */ (false)
25
+
26
+ /**
27
+ * @typedef {Object} DecompressHandlerOptions
28
+ * @property {number[]|Readonly<number[]>} [skipStatusCodes=[204, 304]] - List of status codes to skip decompression for
29
+ * @property {boolean} [skipErrorResponses] - Whether to skip decompression for error responses (status codes >= 400)
30
+ */
31
+
32
+ class DecompressHandler extends DecoratorHandler {
33
+ /** @type {Transform[]} */
34
+ #decompressors = []
35
+ /** @type {NodeJS.WritableStream&NodeJS.ReadableStream|null} */
36
+ #pipelineStream
37
+ /** @type {Readonly<number[]>} */
38
+ #skipStatusCodes
39
+ /** @type {boolean} */
40
+ #skipErrorResponses
41
+
42
+ constructor (handler, { skipStatusCodes = defaultSkipStatusCodes, skipErrorResponses = true } = {}) {
43
+ super(handler)
44
+ this.#skipStatusCodes = skipStatusCodes
45
+ this.#skipErrorResponses = skipErrorResponses
46
+ }
47
+
48
+ /**
49
+ * Determines if decompression should be skipped based on encoding and status code
50
+ * @param {string} contentEncoding - Content-Encoding header value
51
+ * @param {number} statusCode - HTTP status code of the response
52
+ * @returns {boolean} - True if decompression should be skipped
53
+ */
54
+ #shouldSkipDecompression (contentEncoding, statusCode) {
55
+ if (!contentEncoding || statusCode < 200) return true
56
+ if (this.#skipStatusCodes.includes(statusCode)) return true
57
+ if (this.#skipErrorResponses && statusCode >= 400) return true
58
+ return false
59
+ }
60
+
61
+ /**
62
+ * Creates a chain of decompressors for multiple content encodings
63
+ *
64
+ * @param {string} encodings - Comma-separated list of content encodings
65
+ * @returns {Array<DecompressorStream>} - Array of decompressor streams
66
+ */
67
+ #createDecompressionChain (encodings) {
68
+ const parts = encodings.split(',')
69
+
70
+ /** @type {DecompressorStream[]} */
71
+ const decompressors = []
72
+
73
+ for (let i = parts.length - 1; i >= 0; i--) {
74
+ const encoding = parts[i].trim()
75
+ if (!encoding) continue
76
+
77
+ if (!supportedEncodings[encoding]) {
78
+ decompressors.length = 0 // Clear if unsupported encoding
79
+ return decompressors // Unsupported encoding
80
+ }
81
+
82
+ decompressors.push(supportedEncodings[encoding]())
83
+ }
84
+
85
+ return decompressors
86
+ }
87
+
88
+ /**
89
+ * Sets up event handlers for a decompressor stream using readable events
90
+ * @param {DecompressorStream} decompressor - The decompressor stream
91
+ * @param {Controller} controller - The controller to coordinate with
92
+ * @returns {void}
93
+ */
94
+ #setupDecompressorEvents (decompressor, controller) {
95
+ decompressor.on('readable', () => {
96
+ let chunk
97
+ while ((chunk = decompressor.read()) !== null) {
98
+ const result = super.onResponseData(controller, chunk)
99
+ if (result === false) {
100
+ break
101
+ }
102
+ }
103
+ })
104
+
105
+ decompressor.on('error', (error) => {
106
+ super.onResponseError(controller, error)
107
+ })
108
+ }
109
+
110
+ /**
111
+ * Sets up event handling for a single decompressor
112
+ * @param {Controller} controller - The controller to handle events
113
+ * @returns {void}
114
+ */
115
+ #setupSingleDecompressor (controller) {
116
+ const decompressor = this.#decompressors[0]
117
+ this.#setupDecompressorEvents(decompressor, controller)
118
+
119
+ decompressor.on('end', () => {
120
+ super.onResponseEnd(controller, {})
121
+ })
122
+ }
123
+
124
+ /**
125
+ * Sets up event handling for multiple chained decompressors using pipeline
126
+ * @param {Controller} controller - The controller to handle events
127
+ * @returns {void}
128
+ */
129
+ #setupMultipleDecompressors (controller) {
130
+ const lastDecompressor = this.#decompressors[this.#decompressors.length - 1]
131
+ this.#setupDecompressorEvents(lastDecompressor, controller)
132
+
133
+ this.#pipelineStream = pipeline(this.#decompressors, (err) => {
134
+ if (err) {
135
+ super.onResponseError(controller, err)
136
+ return
137
+ }
138
+ super.onResponseEnd(controller, {})
139
+ })
140
+ }
141
+
142
+ /**
143
+ * Cleans up decompressor references to prevent memory leaks
144
+ * @returns {void}
145
+ */
146
+ #cleanupDecompressors () {
147
+ this.#decompressors.length = 0
148
+ this.#pipelineStream = null
149
+ }
150
+
151
+ /**
152
+ * @param {Controller} controller
153
+ * @param {number} statusCode
154
+ * @param {Record<string, string | string[] | undefined>} headers
155
+ * @param {string} statusMessage
156
+ * @returns {void}
157
+ */
158
+ onResponseStart (controller, statusCode, headers, statusMessage) {
159
+ const contentEncoding = headers['content-encoding']
160
+
161
+ // If content encoding is not supported or status code is in skip list
162
+ if (this.#shouldSkipDecompression(contentEncoding, statusCode)) {
163
+ return super.onResponseStart(controller, statusCode, headers, statusMessage)
164
+ }
165
+
166
+ const decompressors = this.#createDecompressionChain(contentEncoding.toLowerCase())
167
+
168
+ if (decompressors.length === 0) {
169
+ this.#cleanupDecompressors()
170
+ return super.onResponseStart(controller, statusCode, headers, statusMessage)
171
+ }
172
+
173
+ this.#decompressors = decompressors
174
+
175
+ // Remove compression headers since we're decompressing
176
+ const { 'content-encoding': _, 'content-length': __, ...newHeaders } = headers
177
+
178
+ if (this.#decompressors.length === 1) {
179
+ this.#setupSingleDecompressor(controller)
180
+ } else {
181
+ this.#setupMultipleDecompressors(controller)
182
+ }
183
+
184
+ super.onResponseStart(controller, statusCode, newHeaders, statusMessage)
185
+ }
186
+
187
+ /**
188
+ * @param {Controller} controller
189
+ * @param {Buffer} chunk
190
+ * @returns {void}
191
+ */
192
+ onResponseData (controller, chunk) {
193
+ if (this.#decompressors.length > 0) {
194
+ this.#decompressors[0].write(chunk)
195
+ return
196
+ }
197
+ super.onResponseData(controller, chunk)
198
+ }
199
+
200
+ /**
201
+ * @param {Controller} controller
202
+ * @param {Record<string, string | string[]> | undefined} trailers
203
+ * @returns {void}
204
+ */
205
+ onResponseEnd (controller, trailers) {
206
+ if (this.#decompressors.length > 0) {
207
+ this.#decompressors[0].end()
208
+ this.#cleanupDecompressors()
209
+ return
210
+ }
211
+ super.onResponseEnd(controller, trailers)
212
+ }
213
+
214
+ /**
215
+ * @param {Controller} controller
216
+ * @param {Error} err
217
+ * @returns {void}
218
+ */
219
+ onResponseError (controller, err) {
220
+ if (this.#decompressors.length > 0) {
221
+ for (const decompressor of this.#decompressors) {
222
+ decompressor.destroy(err)
223
+ }
224
+ this.#cleanupDecompressors()
225
+ }
226
+ super.onResponseError(controller, err)
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Creates a decompression interceptor for HTTP responses
232
+ * @param {DecompressHandlerOptions} [options] - Options for the interceptor
233
+ * @returns {Function} - Interceptor function
234
+ */
235
+ function createDecompressInterceptor (options = {}) {
236
+ // Emit experimental warning only once
237
+ if (!warningEmitted) {
238
+ process.emitWarning(
239
+ 'DecompressInterceptor is experimental and subject to change',
240
+ 'ExperimentalWarning'
241
+ )
242
+ warningEmitted = true
243
+ }
244
+
245
+ return (dispatch) => {
246
+ return (opts, handler) => {
247
+ const decompressHandler = new DecompressHandler(handler, options)
248
+ return dispatch(opts, decompressHandler)
249
+ }
250
+ }
251
+ }
252
+
253
+ module.exports = createDecompressInterceptor
@@ -15,7 +15,7 @@ export declare const H_METHOD_MAP: {
15
15
  [k: string]: number;
16
16
  };
17
17
  export declare const STATUSES_HTTP: number[];
18
- export type CharList = Array<string | number>;
18
+ export type CharList = (string | number)[];
19
19
  export declare const ALPHA: CharList;
20
20
  export declare const NUM_MAP: {
21
21
  0: number;
@@ -95,3 +95,101 @@ export declare const SPECIAL_HEADERS: {
95
95
  'transfer-encoding': number;
96
96
  upgrade: number;
97
97
  };
98
+ declare const _default: {
99
+ ERROR: IntDict;
100
+ TYPE: IntDict;
101
+ FLAGS: IntDict;
102
+ LENIENT_FLAGS: IntDict;
103
+ METHODS: IntDict;
104
+ STATUSES: IntDict;
105
+ FINISH: IntDict;
106
+ HEADER_STATE: IntDict;
107
+ ALPHA: CharList;
108
+ NUM_MAP: {
109
+ 0: number;
110
+ 1: number;
111
+ 2: number;
112
+ 3: number;
113
+ 4: number;
114
+ 5: number;
115
+ 6: number;
116
+ 7: number;
117
+ 8: number;
118
+ 9: number;
119
+ };
120
+ HEX_MAP: {
121
+ 0: number;
122
+ 1: number;
123
+ 2: number;
124
+ 3: number;
125
+ 4: number;
126
+ 5: number;
127
+ 6: number;
128
+ 7: number;
129
+ 8: number;
130
+ 9: number;
131
+ A: number;
132
+ B: number;
133
+ C: number;
134
+ D: number;
135
+ E: number;
136
+ F: number;
137
+ a: number;
138
+ b: number;
139
+ c: number;
140
+ d: number;
141
+ e: number;
142
+ f: number;
143
+ };
144
+ NUM: CharList;
145
+ ALPHANUM: CharList;
146
+ MARK: CharList;
147
+ USERINFO_CHARS: CharList;
148
+ URL_CHAR: CharList;
149
+ HEX: CharList;
150
+ TOKEN: CharList;
151
+ HEADER_CHARS: CharList;
152
+ CONNECTION_TOKEN_CHARS: CharList;
153
+ QUOTED_STRING: CharList;
154
+ HTAB_SP_VCHAR_OBS_TEXT: CharList;
155
+ MAJOR: {
156
+ 0: number;
157
+ 1: number;
158
+ 2: number;
159
+ 3: number;
160
+ 4: number;
161
+ 5: number;
162
+ 6: number;
163
+ 7: number;
164
+ 8: number;
165
+ 9: number;
166
+ };
167
+ MINOR: {
168
+ 0: number;
169
+ 1: number;
170
+ 2: number;
171
+ 3: number;
172
+ 4: number;
173
+ 5: number;
174
+ 6: number;
175
+ 7: number;
176
+ 8: number;
177
+ 9: number;
178
+ };
179
+ SPECIAL_HEADERS: {
180
+ connection: number;
181
+ 'content-length': number;
182
+ 'proxy-connection': number;
183
+ 'transfer-encoding': number;
184
+ upgrade: number;
185
+ };
186
+ METHODS_HTTP: number[];
187
+ METHODS_ICE: number[];
188
+ METHODS_RTSP: number[];
189
+ METHOD_MAP: IntDict;
190
+ H_METHOD_MAP: {
191
+ [k: string]: number;
192
+ };
193
+ STATUSES_HTTP: number[];
194
+ };
195
+ export default _default;
@@ -40,6 +40,7 @@ exports.ERROR = {
40
40
  CB_CHUNK_EXTENSION_NAME_COMPLETE: 34,
41
41
  CB_CHUNK_EXTENSION_VALUE_COMPLETE: 35,
42
42
  CB_RESET: 31,
43
+ CB_PROTOCOL_COMPLETE: 38,
43
44
  };
44
45
  exports.TYPE = {
45
46
  BOTH: 0, // default
@@ -495,4 +496,36 @@ exports.SPECIAL_HEADERS = {
495
496
  'transfer-encoding': exports.HEADER_STATE.TRANSFER_ENCODING,
496
497
  'upgrade': exports.HEADER_STATE.UPGRADE,
497
498
  };
498
- //# sourceMappingURL=constants.js.map
499
+ exports.default = {
500
+ ERROR: exports.ERROR,
501
+ TYPE: exports.TYPE,
502
+ FLAGS: exports.FLAGS,
503
+ LENIENT_FLAGS: exports.LENIENT_FLAGS,
504
+ METHODS: exports.METHODS,
505
+ STATUSES: exports.STATUSES,
506
+ FINISH: exports.FINISH,
507
+ HEADER_STATE: exports.HEADER_STATE,
508
+ ALPHA: exports.ALPHA,
509
+ NUM_MAP: exports.NUM_MAP,
510
+ HEX_MAP: exports.HEX_MAP,
511
+ NUM: exports.NUM,
512
+ ALPHANUM: exports.ALPHANUM,
513
+ MARK: exports.MARK,
514
+ USERINFO_CHARS: exports.USERINFO_CHARS,
515
+ URL_CHAR: exports.URL_CHAR,
516
+ HEX: exports.HEX,
517
+ TOKEN: exports.TOKEN,
518
+ HEADER_CHARS: exports.HEADER_CHARS,
519
+ CONNECTION_TOKEN_CHARS: exports.CONNECTION_TOKEN_CHARS,
520
+ QUOTED_STRING: exports.QUOTED_STRING,
521
+ HTAB_SP_VCHAR_OBS_TEXT: exports.HTAB_SP_VCHAR_OBS_TEXT,
522
+ MAJOR: exports.MAJOR,
523
+ MINOR: exports.MINOR,
524
+ SPECIAL_HEADERS: exports.SPECIAL_HEADERS,
525
+ METHODS_HTTP: exports.METHODS_HTTP,
526
+ METHODS_ICE: exports.METHODS_ICE,
527
+ METHODS_RTSP: exports.METHODS_RTSP,
528
+ METHOD_MAP: exports.METHOD_MAP,
529
+ H_METHOD_MAP: exports.H_METHOD_MAP,
530
+ STATUSES_HTTP: exports.STATUSES_HTTP,
531
+ };