aboutsummaryrefslogtreecommitdiffstats
path: root/vanilla/node_modules/undici/lib/interceptor/decompress.js
diff options
context:
space:
mode:
Diffstat (limited to 'vanilla/node_modules/undici/lib/interceptor/decompress.js')
-rw-r--r--vanilla/node_modules/undici/lib/interceptor/decompress.js259
1 files changed, 259 insertions, 0 deletions
diff --git a/vanilla/node_modules/undici/lib/interceptor/decompress.js b/vanilla/node_modules/undici/lib/interceptor/decompress.js
new file mode 100644
index 0000000..ee4202a
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/interceptor/decompress.js
@@ -0,0 +1,259 @@
+'use strict'
+
+const { createInflate, createGunzip, createBrotliDecompress, createZstdDecompress } = require('node:zlib')
+const { pipeline } = require('node:stream')
+const DecoratorHandler = require('../handler/decorator-handler')
+const { runtimeFeatures } = require('../util/runtime-features')
+
+/** @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,
+ ...(runtimeFeatures.has('zstd') ? { 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 {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
+ * @throws {Error} - If the number of content-encodings exceeds the maximum allowed
+ */
+ #createDecompressionChain (encodings) {
+ const parts = encodings.split(',')
+
+ // Limit the number of content-encodings to prevent resource exhaustion.
+ // CVE fix similar to urllib3 (GHSA-gm62-xv2j-4w53) and curl (CVE-2022-32206).
+ const maxContentEncodings = 5
+ if (parts.length > maxContentEncodings) {
+ throw new Error(`too many content-encodings in response: ${parts.length}, maximum allowed is ${maxContentEncodings}`)
+ }
+
+ /** @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)
+
+ 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
+ }
+
+ /**
+ * @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)
+ }
+
+ return 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