From d4d4939001152b46897320076eaf9efc47c3fe7c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 16 Oct 2024 12:05:57 +0200 Subject: [PATCH] feat: use compression streams to decompress responses --- src/interceptors/fetch/index.ts | 28 ++++-- src/interceptors/fetch/utils/compression.ts | 94 +++++++++++++++++++ .../response-content-encoding.test.ts | 36 +++++-- 3 files changed, 142 insertions(+), 16 deletions(-) create mode 100644 src/interceptors/fetch/utils/compression.ts diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index 14278b6d..c24e703c 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -10,6 +10,7 @@ import { createRequestId } from '../../createRequestId' import { RESPONSE_STATUS_CODES_WITH_REDIRECT } from '../../utils/responseUtils' import { createNetworkError } from './utils/createNetworkError' import { followFetchRedirect } from './utils/followRedirect' +import { decompressResponse } from './utils/compression' export class FetchInterceptor extends Interceptor { static symbol = Symbol('fetch') @@ -66,11 +67,18 @@ export class FetchInterceptor extends Interceptor { requestId, emitter: this.emitter, controller, - onResponse: async (response) => { + onResponse: async (rawResponse) => { this.logger.info('received mocked response!', { - response, + rawResponse, }) + // Decompress the mocked response body, if applicable. + const decompressedStream = decompressResponse(rawResponse) + const response = + decompressedStream === null + ? rawResponse + : new Response(decompressedStream, rawResponse) + /** * Undici's handling of following redirect responses. * Treat the "manual" redirect mode as a regular mocked response. @@ -98,6 +106,14 @@ export class FetchInterceptor extends Interceptor { } } + // Set the "response.url" property to equal the intercepted request URL. + Object.defineProperty(response, 'url', { + writable: false, + enumerable: true, + configurable: false, + value: request.url, + }) + if (this.emitter.listenerCount('response') > 0) { this.logger.info('emitting the "response" event...') @@ -115,14 +131,6 @@ export class FetchInterceptor extends Interceptor { }) } - // Set the "response.url" property to equal the intercepted request URL. - Object.defineProperty(response, 'url', { - writable: false, - enumerable: true, - configurable: false, - value: request.url, - }) - responsePromise.resolve(response) }, onRequestError: (response) => { diff --git a/src/interceptors/fetch/utils/compression.ts b/src/interceptors/fetch/utils/compression.ts new file mode 100644 index 00000000..cdea97a9 --- /dev/null +++ b/src/interceptors/fetch/utils/compression.ts @@ -0,0 +1,94 @@ +function pipeline(streams: Array): TransformStream { + if (streams.length === 0) { + throw new Error('At least one stream must be provided') + } + + let composedStream = streams[0] + + for (let i = 1; i < streams.length; i++) { + const currentStream = streams[i] + + composedStream = new TransformStream({ + async start(controller) { + const reader = streams[i - 1].readable.getReader() + const writer = currentStream.writable.getWriter() + + while (true) { + const { value, done } = await reader.read() + if (done) { + break + } + await writer.write(value) + } + + await writer.close() + controller.terminate() + }, + transform(chunk, controller) { + controller.enqueue(chunk) + }, + }) + } + + return composedStream +} + +function createDecompressionStream( + contentEncoding: string +): TransformStream | null { + if (contentEncoding === '') { + return null + } + + const codings = contentEncoding + .toLowerCase() + .split(',') + .map((coding) => coding.trim()) + + if (codings.length === 0) { + return null + } + + const transformers: Array = [] + + for (let i = codings.length - 1; i >= 0; --i) { + const coding = codings[i] + + if (coding === 'gzip' || coding === 'x-gzip') { + transformers.push(new DecompressionStream('gzip')) + } else if (coding === 'deflate') { + transformers.push(new DecompressionStream('deflate')) + } else if (coding === 'br') { + /** + * @todo Support Brotli decompression. + * It's not a part of the web Compression Streams API. + */ + } else { + transformers.length = 0 + } + } + + return pipeline(transformers) +} + +export function decompressResponse( + response: Response +): ReadableStream | null { + if (response.body === null) { + return null + } + + const decompressionStream = createDecompressionStream( + response.headers.get('content-encoding') || '' + ) + + if (!decompressionStream) { + return null + } + + // Use `pipeTo` and return the decompression stream's readable + // instead of `pipeThrough` because that will lock the original + // response stream, making it unusable as the input to Response. + response.body.pipeTo(decompressionStream.writable) + return decompressionStream.readable +} diff --git a/test/modules/fetch/compliance/response-content-encoding.test.ts b/test/modules/fetch/compliance/response-content-encoding.test.ts index f6bd7458..8220ee69 100644 --- a/test/modules/fetch/compliance/response-content-encoding.test.ts +++ b/test/modules/fetch/compliance/response-content-encoding.test.ts @@ -29,6 +29,36 @@ afterAll(async () => { await httpServer.close() }) +it('decompresses a mocked "gzip" encoded response body', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(zlib.gzipSync('hello world'), { + headers: { + 'Content-Encoding': 'gzip', + }, + }) + ) + }) + + const response = await fetch('http://localhost/resource') + expect(await response.text()).toBe('hello world') +}) + +it('decompresses a mocked "deflate" encoded response body', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(zlib.deflateSync('hello world'), { + headers: { + 'Content-Encoding': 'deflate', + }, + }) + ) + }) + + const response = await fetch('http://localhost/resource') + expect(await response.text()).toBe('hello world') +}) + it('decompresses a mocked "content-encoding: gzip, br" response body', async () => { interceptor.on('request', ({ controller }) => { controller.respondWith( @@ -41,16 +71,10 @@ it('decompresses a mocked "content-encoding: gzip, br" response body', async () }) const response = await fetch('http://localhost/resource') - - expect(response.status).toBe(200) - // Must read as decompressed response. expect(await response.text()).toBe('hello world') }) it('decompresses a bypassed "content-encoding: gzip, br" response body', async () => { const response = await fetch(httpServer.http.url('/compressed')) - - expect(response.status).toBe(200) - // Must read as decompressed response. expect(await response.text()).toBe('hello world') })