Skip to content

Decompression Interceptor #4316

@FelixVaughan

Description

@FelixVaughan

Add decompression interceptor for automatic response decompression

On #3253, a future proposal was made for the addition of a decompression interceptor and I think this would be a worthwhile feature.

Benefits

Currently, fetch() automatically handles compression while request() requires manual decompression. This interceptor bridges that gap, making request() behavior consistent with fetch() and reduces boilerplate for handling compressed responses.

Implementation Details

  • Supports gzip, deflate, and brotli compression
  • Streaming decompression (memory efficient)
  • Proper header cleanup (removes content-encoding/length)
  • Error handling with proper cleanup
  • Comprehensive test coverage

Usage Example

const { Client } = require('undici')
const createDecompressInterceptor = require('./decompress')

const client = new Client('https://api.example.com')
  .compose(createDecompressInterceptor())

// Now all responses are automatically decompressed
const response = await client.request({ path: '/data' })

Implementation

I've implemented a solution (will link draft PR) with tests and would be interested in getting some feedback. Below is what the interceptor would look like:

'use strict'

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

class DecompressHandler extends DecoratorHandler {
  #decompressor = null

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

    const shouldSkip = !contentEncoding || statusCode < 200 || statusCode >= 400 || [204, 304].includes(statusCode)

    if (shouldSkip) {
      return super.onResponseStart(controller, statusCode, headers, statusMessage)
    }

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

    const supportedEncodings = {
      gzip: createGunzip,
      deflate: createInflate,
      br: createBrotliDecompress
    }

    const createDecompressor = supportedEncodings[contentEncoding.toLowerCase()]
    if (!createDecompressor) {
      return super.onResponseStart(controller, statusCode, headers, statusMessage)
    }

    this.#decompressor = createDecompressor()

    // Set up decompressor event handlers
    this.#decompressor.on('data', (chunk) => {
      super.onResponseData(controller, chunk)
    })

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

    this.#decompressor.on('finish', () => {
      super.onResponseEnd(controller, {})
    })

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

  onResponseData (controller, chunk) {
    // If we have a decompressor, pipe data through it
    if (this.#decompressor) {
      this.#decompressor.write(chunk)
      return true
    }
    return super.onResponseData(controller, chunk)
  }

  onResponseEnd (controller, trailers) {
    if (this.#decompressor) {
      this.#decompressor.end()
      this.#decompressor = null
      return
    }
    return super.onResponseEnd(controller, trailers)
  }

  onResponseError (controller, err) {
    if (this.#decompressor) {
      this.#decompressor.destroy(err)
      this.#decompressor = null
    }
    return super.onResponseError(controller, err)
  }
}

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

module.exports = createDecompressInterceptor

I'm happy to finish this up and add documentation if this is something that would be of benefit.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions