Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 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
57 changes: 57 additions & 0 deletions docs/docs/api/Dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,63 @@ await client.request({
});
```

##### `decompress`

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 @@ -45,7 +45,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
188 changes: 188 additions & 0 deletions lib/interceptor/decompress.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
'use strict'

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

class DecompressHandler extends DecoratorHandler {
#decompressors = []
#pipelineStream = null
#skipStatusCodes
#skipErrorResponses

constructor (handler, { skipStatusCodes = [204, 304], skipErrorResponses = true } = {}) {
super(handler)
this.#skipStatusCodes = skipStatusCodes
this.#skipErrorResponses = skipErrorResponses
}

#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
}

#createDecompressor (encoding) {
const supportedEncodings = {
gzip: createGunzip,
'x-gzip': createGunzip,
br: createBrotliDecompress,
zstd: createZstdDecompress,
deflate: createInflate,
compress: createInflate,
'x-compress': createInflate
}

const createDecompressor = supportedEncodings[encoding.toLowerCase()]
return createDecompressor ? createDecompressor() : null
}

#createDecompressionChain (encodings) {
const encodingList = encodings.split(',').map(e => e.trim()).filter(Boolean)

const decompressors = encodingList
.reverse()
.map(encoding => this.#createDecompressor(encoding))

return decompressors.some(d => !d) ? null : decompressors
}

onResponseStart (controller, statusCode, headers, statusMessage) {
const contentEncoding = headers['content-encoding']

if (this.#shouldSkipDecompression(contentEncoding, statusCode)) {
return super.onResponseStart(controller, statusCode, headers, statusMessage)
}

const decompressors = this.#createDecompressionChain(contentEncoding)

if (!decompressors) {
this.#decompressors = []
return super.onResponseStart(controller, statusCode, headers, statusMessage)
}

this.#decompressors = decompressors

const { 'content-encoding': _, 'content-length': __, ...newHeaders } = headers

const superPause = controller.pause.bind(controller)
const superResume = controller.resume.bind(controller)

controller.pause = () => {
const result = superPause()
if (this.#decompressors.length > 0) {
const head = this.#decompressors[0]
if (!head.readableEnded && !head.destroyed) {
head.pause()
}
if (this.#pipelineStream && !this.#pipelineStream.destroyed) {
this.#pipelineStream.pause()
}
}
return result
}

controller.resume = () => {
const result = superResume()
if (this.#decompressors.length > 0) {
const head = this.#decompressors[0]
if (!head.readableEnded && !head.destroyed) {
head.resume()
}
if (this.#pipelineStream && !this.#pipelineStream.destroyed) {
this.#pipelineStream.resume()
}
}
return result
}

if (this.#decompressors.length === 1) {
const decompressor = this.#decompressors[0]
decompressor.on('data', (chunk) => {
const result = super.onResponseData(controller, chunk)
if (result === false) {
decompressor.pause()
}
})

decompressor.on('drain', () => {
controller.resume()
})

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

decompressor.on('finish', () => {
super.onResponseEnd(controller, {})
})
} else {
const lastDecompressor = this.#decompressors[this.#decompressors.length - 1]

lastDecompressor.on('data', (chunk) => {
const result = super.onResponseData(controller, chunk)
if (result === false) {
lastDecompressor.pause()
}
})

lastDecompressor.on('drain', () => {
controller.resume()
})

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

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

onResponseData (controller, chunk) {
if (this.#decompressors.length > 0) {
const writeResult = this.#decompressors[0].write(chunk)
if (writeResult === false) {
controller.pause()
}
return true
}
return super.onResponseData(controller, chunk)
}

onResponseEnd (controller, trailers) {
if (this.#decompressors.length > 0) {
this.#decompressors[0].end()
this.#decompressors = []
this.#pipelineStream = null
return
}
return super.onResponseEnd(controller, trailers)
}

onResponseError (controller, err) {
if (this.#decompressors.length > 0) {
this.#decompressors.forEach(d => {
d.destroy(err)
})
this.#decompressors = []
this.#pipelineStream = null
}
return super.onResponseError(controller, err)
}
}

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

module.exports = createDecompressInterceptor
Loading
Loading