Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9c4b391
undici vs fetch
FelixVaughan May 28, 2025
d9a9921
snackin
FelixVaughan May 29, 2025
cbcbe2a
nothing serious
FelixVaughan Jun 27, 2025
58553bc
todo: tests
FelixVaughan Jun 30, 2025
fa633ec
testing
FelixVaughan Jul 2, 2025
f0e3f6a
resolve merge conflict
FelixVaughan Jul 2, 2025
c640901
cleanup
FelixVaughan Jul 2, 2025
f0266eb
Update lib/interceptor/decompress.js
FelixVaughan Jul 12, 2025
572eb75
resolve conflict
FelixVaughan Jul 13, 2025
1accabb
documentation
FelixVaughan Jul 13, 2025
d5bf430
formatting
FelixVaughan Jul 13, 2025
74cc73f
pr suggestions
FelixVaughan Jul 15, 2025
ae7847c
pr suggestions as well as zstd support
FelixVaughan Jul 17, 2025
8294096
update documatation
FelixVaughan Jul 17, 2025
d229312
personal pedancy
FelixVaughan Jul 17, 2025
af7c01b
pr suggestions
FelixVaughan Jul 21, 2025
88c6170
tidied up decompression chaining logic
FelixVaughan Jul 22, 2025
13783dc
wip
FelixVaughan Jul 27, 2025
8d003fb
pr suggestions and tests
FelixVaughan Jul 28, 2025
fa8db99
skip createZstdCompress tests when unavailable (pre v22)
FelixVaughan Jul 28, 2025
8ca18ec
tidy up
FelixVaughan Jul 29, 2025
08d34d1
conditional usage of createZstdDecompress
FelixVaughan Jul 29, 2025
a411ae1
added some comments (mostly to re-trigger CI/CD)
FelixVaughan Jul 29, 2025
083073c
Added tests with fetch()
FelixVaughan Jul 30, 2025
70a291d
refactored createDecompressionChain to use a basic for loop
FelixVaughan Aug 4, 2025
dddd2a0
Merge branch 'main' of github.com:nodejs/undici into decomp-interceptor
FelixVaughan Aug 14, 2025
9d5255a
apply changes
Uzlopak Aug 17, 2025
87fb219
improved jsdocs, removed non-critical handler return statements
FelixVaughan Aug 17, 2025
2bc4323
experimental flag in dispatcher.md for decompress
FelixVaughan Aug 18, 2025
00d869e
plain object use
FelixVaughan Aug 19, 2025
c07802d
readble event
FelixVaughan Aug 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/docs/api/Dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,65 @@ await client.request({
});
```

##### `decompress`

⚠️ The decompress interceptor is experimental and subject to change.

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.

**Options**

- `skipErrorResponses` - Whether to skip decompression for error responses (status codes >= 400). Default: `true`.
- `skipStatusCodes` - Array of status codes to skip decompression for. Default: `[204, 304]`.

**Example - Basic Decompress Interceptor**

```js
const { Client, interceptors } = require("undici");
const { decompress } = interceptors;

const client = new Client("http://example.com").compose(
decompress()
);

// Automatically decompresses gzip/deflate/brotli/zstd responses
const response = await client.request({
method: "GET",
path: "/"
});
```

**Example - Custom Options**

```js
const { Client, interceptors } = require("undici");
const { decompress } = interceptors;

const client = new Client("http://example.com").compose(
decompress({
skipErrorResponses: false, // Decompress 5xx responses
skipStatusCodes: [204, 304, 201] // Skip these status codes
})
);
```

**Supported Encodings**

- `gzip` / `x-gzip` - GZIP compression
- `deflate` / `x-compress` - DEFLATE compression
- `br` - Brotli compression
- `zstd` - Zstandard compression
- Multiple encodings (e.g., `gzip, deflate`) are supported per RFC-9110

**Behavior**

- Skips decompression for status codes < 200 or >= 400 (configurable)
- Skips decompression for 204 No Content and 304 Not Modified by default
- Removes `content-encoding` and `content-length` headers when decompressing
- Passes through unsupported encodings unchanged
- Handles case-insensitive encoding names
- Supports streaming decompression without buffering

##### `Cache Interceptor`

The `cache` interceptor implements client-side response caching as described in
Expand Down
3 changes: 2 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ module.exports.interceptors = {
retry: require('./lib/interceptor/retry'),
dump: require('./lib/interceptor/dump'),
dns: require('./lib/interceptor/dns'),
cache: require('./lib/interceptor/cache')
cache: require('./lib/interceptor/cache'),
decompress: require('./lib/interceptor/decompress')
}

module.exports.cacheStores = {
Expand Down
253 changes: 253 additions & 0 deletions lib/interceptor/decompress.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
'use strict'

const { createInflate, createGunzip, createBrotliDecompress, createZstdDecompress } = require('node:zlib')
const { pipeline } = require('node:stream')
const DecoratorHandler = require('../handler/decorator-handler')

/** @typedef {import('node:stream').Transform} Transform */
/** @typedef {import('node:stream').Transform} Controller */
/** @typedef {Transform&import('node:zlib').Zlib} DecompressorStream */

/** @type {Record<string, () => DecompressorStream>} */
const supportedEncodings = {
gzip: createGunzip,
'x-gzip': createGunzip,
br: createBrotliDecompress,
deflate: createInflate,
compress: createInflate,
'x-compress': createInflate,
...(createZstdDecompress ? { zstd: createZstdDecompress } : {})
}

const defaultSkipStatusCodes = /** @type {const} */ ([204, 304])

let warningEmitted = /** @type {boolean} */ (false)

/**
* @typedef {Object} DecompressHandlerOptions
* @property {number[]|Readonly<number[]>} [skipStatusCodes=[204, 304]] - List of status codes to skip decompression for
* @property {boolean} [skipErrorResponses] - Whether to skip decompression for error responses (status codes >= 400)
*/

class DecompressHandler extends DecoratorHandler {
/** @type {Transform[]} */
#decompressors = []
/** @type {NodeJS.WritableStream&NodeJS.ReadableStream|null} */
#pipelineStream
/** @type {Readonly<number[]>} */
#skipStatusCodes
/** @type {boolean} */
#skipErrorResponses

constructor (handler, { skipStatusCodes = defaultSkipStatusCodes, skipErrorResponses = true } = {}) {
super(handler)
this.#skipStatusCodes = skipStatusCodes
this.#skipErrorResponses = skipErrorResponses
}

/**
* Determines if decompression should be skipped based on encoding and status code
* @param {string} contentEncoding - Content-Encoding header value
* @param {number} statusCode - HTTP status code of the response
* @returns {boolean} - True if decompression should be skipped
*/
#shouldSkipDecompression (contentEncoding, statusCode) {
if (!contentEncoding || statusCode < 200) return true
if (this.#skipStatusCodes.includes(statusCode)) return true
if (this.#skipErrorResponses && statusCode >= 400) return true
return false
}

/**
* Creates a chain of decompressors for multiple content encodings
*
* @param {string} encodings - Comma-separated list of content encodings
* @returns {Array<DecompressorStream>} - Array of decompressor streams
*/
#createDecompressionChain (encodings) {
const parts = encodings.split(',')

/** @type {DecompressorStream[]} */
const decompressors = []

for (let i = parts.length - 1; i >= 0; i--) {
const encoding = parts[i].trim()
if (!encoding) continue

if (!supportedEncodings[encoding]) {
decompressors.length = 0 // Clear if unsupported encoding
return decompressors // Unsupported encoding
}

decompressors.push(supportedEncodings[encoding]())
}

return decompressors
}

/**
* Sets up event handlers for a decompressor stream using readable events
* @param {DecompressorStream} decompressor - The decompressor stream
* @param {Controller} controller - The controller to coordinate with
* @returns {void}
*/
#setupDecompressorEvents (decompressor, controller) {
decompressor.on('readable', () => {
let chunk
while ((chunk = decompressor.read()) !== null) {
const result = super.onResponseData(controller, chunk)
if (result === false) {
break
}
}
})

decompressor.on('error', (error) => {
super.onResponseError(controller, error)
})
}

/**
* Sets up event handling for a single decompressor
* @param {Controller} controller - The controller to handle events
* @returns {void}
*/
#setupSingleDecompressor (controller) {
const decompressor = this.#decompressors[0]
this.#setupDecompressorEvents(decompressor, controller)

decompressor.on('end', () => {
super.onResponseEnd(controller, {})
})
}

/**
* Sets up event handling for multiple chained decompressors using pipeline
* @param {Controller} controller - The controller to handle events
* @returns {void}
*/
#setupMultipleDecompressors (controller) {
const lastDecompressor = this.#decompressors[this.#decompressors.length - 1]
this.#setupDecompressorEvents(lastDecompressor, controller)

this.#pipelineStream = pipeline(this.#decompressors, (err) => {
if (err) {
super.onResponseError(controller, err)
return
}
super.onResponseEnd(controller, {})
})
}

/**
* Cleans up decompressor references to prevent memory leaks
* @returns {void}
*/
#cleanupDecompressors () {
this.#decompressors.length = 0
this.#pipelineStream = null
}

/**
* @param {Controller} controller
* @param {number} statusCode
* @param {Record<string, string | string[] | undefined>} headers
* @param {string} statusMessage
* @returns {void}
*/
onResponseStart (controller, statusCode, headers, statusMessage) {
const contentEncoding = headers['content-encoding']

// If content encoding is not supported or status code is in skip list
if (this.#shouldSkipDecompression(contentEncoding, statusCode)) {
return super.onResponseStart(controller, statusCode, headers, statusMessage)
}

const decompressors = this.#createDecompressionChain(contentEncoding.toLowerCase())

if (decompressors.length === 0) {
this.#cleanupDecompressors()
return super.onResponseStart(controller, statusCode, headers, statusMessage)
}

this.#decompressors = decompressors

// Remove compression headers since we're decompressing
const { 'content-encoding': _, 'content-length': __, ...newHeaders } = headers

if (this.#decompressors.length === 1) {
this.#setupSingleDecompressor(controller)
} else {
this.#setupMultipleDecompressors(controller)
}

super.onResponseStart(controller, statusCode, newHeaders, statusMessage)
}

/**
* @param {Controller} controller
* @param {Buffer} chunk
* @returns {void}
*/
onResponseData (controller, chunk) {
if (this.#decompressors.length > 0) {
this.#decompressors[0].write(chunk)
return
}
super.onResponseData(controller, chunk)
}

/**
* @param {Controller} controller
* @param {Record<string, string | string[]> | undefined} trailers
* @returns {void}
*/
onResponseEnd (controller, trailers) {
if (this.#decompressors.length > 0) {
this.#decompressors[0].end()
this.#cleanupDecompressors()
return
}
super.onResponseEnd(controller, trailers)
}

/**
* @param {Controller} controller
* @param {Error} err
* @returns {void}
*/
onResponseError (controller, err) {
if (this.#decompressors.length > 0) {
for (const decompressor of this.#decompressors) {
decompressor.destroy(err)
}
this.#cleanupDecompressors()
}
super.onResponseError(controller, err)
}
}

/**
* Creates a decompression interceptor for HTTP responses
* @param {DecompressHandlerOptions} [options] - Options for the interceptor
* @returns {Function} - Interceptor function
*/
function createDecompressInterceptor (options = {}) {
// Emit experimental warning only once
if (!warningEmitted) {
process.emitWarning(
'DecompressInterceptor is experimental and subject to change',
'ExperimentalWarning'
)
warningEmitted = true
}

return (dispatch) => {
return (opts, handler) => {
const decompressHandler = new DecompressHandler(handler, options)
return dispatch(opts, decompressHandler)
}
}
}

module.exports = createDecompressInterceptor
Loading
Loading