jsdom
Version:
A JavaScript implementation of many web standards
185 lines (150 loc) • 5.14 kB
JavaScript
"use strict";
// Adapted from undici's lib/interceptor/decompress.js
// https://github.com/nodejs/undici/blob/main/lib/interceptor/decompress.js
// Changes:
// - Removed zstd support (requires runtimeFeatures check)
// - Removed experimental warning
// - Use undici's exported DecoratorHandler
const { createInflate, createGunzip, createBrotliDecompress } = require("zlib");
const { pipeline } = require("stream");
const { DecoratorHandler } = require("undici");
const supportedEncodings = {
"gzip": createGunzip,
"x-gzip": createGunzip,
"br": createBrotliDecompress,
"deflate": createInflate,
"compress": createInflate,
"x-compress": createInflate
};
const defaultSkipStatusCodes = [204, 304];
class DecompressHandler extends DecoratorHandler {
#decompressors = [];
#skipStatusCodes;
#skipErrorResponses;
constructor(handler, { skipStatusCodes = defaultSkipStatusCodes, 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;
}
#createDecompressionChain(encodings) {
const parts = encodings.split(",");
const maxContentEncodings = 5;
if (parts.length > maxContentEncodings) {
throw new Error(`too many content-encodings in response: ${parts.length}, max is ${maxContentEncodings}`);
}
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;
return decompressors;
}
decompressors.push(supportedEncodings[encoding]());
}
return decompressors;
}
#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);
});
}
#setupSingleDecompressor(controller) {
const decompressor = this.#decompressors[0];
this.#setupDecompressorEvents(decompressor, controller);
decompressor.on("end", () => {
super.onResponseEnd(controller, {});
});
}
#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, {});
});
}
#cleanupDecompressors() {
this.#decompressors.length = 0;
}
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.toLowerCase());
if (decompressors.length === 0) {
this.#cleanupDecompressors();
return super.onResponseStart(controller, statusCode, headers, statusMessage);
}
this.#decompressors = decompressors;
// Keep content-encoding and content-length headers as-is
// XHR spec requires these to reflect the wire format, not the decoded body
const newHeaders = { ...headers };
if (this.#decompressors.length === 1) {
this.#setupSingleDecompressor(controller);
} else {
this.#setupMultipleDecompressors(controller);
}
return super.onResponseStart(controller, statusCode, newHeaders, statusMessage);
}
onResponseData(controller, chunk) {
if (this.#decompressors.length > 0) {
this.#decompressors[0].write(chunk);
return;
}
super.onResponseData(controller, chunk);
}
onResponseEnd(controller, trailers) {
if (this.#decompressors.length > 0) {
this.#decompressors[0].end();
this.#cleanupDecompressors();
return;
}
super.onResponseEnd(controller, trailers);
}
onResponseError(controller, err) {
if (this.#decompressors.length > 0) {
for (const decompressor of this.#decompressors) {
decompressor.destroy(err);
}
this.#cleanupDecompressors();
}
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;