diff --git a/package.json b/package.json index c63ff3e8..6f23ab41 100644 --- a/package.json +++ b/package.json @@ -200,4 +200,4 @@ "path": "./node_modules/cz-conventional-changelog" } } -} \ No newline at end of file +} diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index 14278b6d..6fffcfcf 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/decompression' 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/brotli-decompress.browser.ts b/src/interceptors/fetch/utils/brotli-decompress.browser.ts new file mode 100644 index 00000000..d88ca784 --- /dev/null +++ b/src/interceptors/fetch/utils/brotli-decompress.browser.ts @@ -0,0 +1,14 @@ +export class BrotliDecompressionStream extends TransformStream { + constructor() { + console.warn( + '[Interceptors]: Brotli decompression of response streams is not supported in the browser' + ) + + super({ + transform(chunk, controller) { + // Keep the stream as passthrough, it does nothing. + controller.enqueue(chunk) + }, + }) + } +} diff --git a/src/interceptors/fetch/utils/brotli-decompress.ts b/src/interceptors/fetch/utils/brotli-decompress.ts new file mode 100644 index 00000000..dbfa622e --- /dev/null +++ b/src/interceptors/fetch/utils/brotli-decompress.ts @@ -0,0 +1,31 @@ +import zlib from 'node:zlib' + +export class BrotliDecompressionStream extends TransformStream { + constructor() { + const decompress = zlib.createBrotliDecompress({ + flush: zlib.constants.BROTLI_OPERATION_FLUSH, + finishFlush: zlib.constants.BROTLI_OPERATION_FLUSH, + }) + + super({ + async transform(chunk, controller) { + const buffer = Buffer.from(chunk) + + const decompressed = await new Promise((resolve, reject) => { + decompress.write(buffer, (error) => { + if (error) reject(error) + }) + + decompress.flush() + decompress.once('data', (data) => resolve(data)) + decompress.once('error', (error) => reject(error)) + decompress.once('end', () => controller.terminate()) + }).catch((error) => { + controller.error(error) + }) + + controller.enqueue(decompressed) + }, + }) + } +} diff --git a/src/interceptors/fetch/utils/decompression.ts b/src/interceptors/fetch/utils/decompression.ts new file mode 100644 index 00000000..583d4a37 --- /dev/null +++ b/src/interceptors/fetch/utils/decompression.ts @@ -0,0 +1,85 @@ +// Import from an internal alias that resolves to different modules +// depending on the environment. This way, we can keep the fetch interceptor +// intact while using different strategies for Brotli decompression. +import { BrotliDecompressionStream } from 'internal:brotli-decompress' + +class PipelineStream extends TransformStream { + constructor( + transformStreams: Array, + ...strategies: Array + ) { + super({}, ...strategies) + + const readable = [super.readable as any, ...transformStreams].reduce( + (readable, transform) => readable.pipeThrough(transform) + ) + + Object.defineProperty(this, 'readable', { + get() { + return readable + }, + }) + } +} + +export function parseContentEncoding(contentEncoding: string): Array { + return contentEncoding + .toLowerCase() + .split(',') + .map((coding) => coding.trim()) +} + +function createDecompressionStream( + contentEncoding: string +): TransformStream | null { + if (contentEncoding === '') { + return null + } + + const codings = parseContentEncoding(contentEncoding) + + if (codings.length === 0) { + return null + } + + const transformers = codings.reduceRight>( + (transformers, coding) => { + if (coding === 'gzip' || coding === 'x-gzip') { + return transformers.concat(new DecompressionStream('gzip')) + } else if (coding === 'deflate') { + return transformers.concat(new DecompressionStream('deflate')) + } else if (coding === 'br') { + return transformers.concat(new BrotliDecompressionStream()) + } else { + transformers.length = 0 + } + + return transformers + }, + [] + ) + + return new PipelineStream(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/helpers.ts b/test/helpers.ts index e48d5277..02197e4a 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,5 +1,6 @@ import { urlToHttpOptions } from 'node:url' import https from 'node:https' +import zlib from 'node:zlib' import http, { ClientRequest, IncomingMessage, RequestOptions } from 'node:http' import nodeFetch, { Response, RequestInfo, RequestInit } from 'node-fetch' import { Page } from '@playwright/test' @@ -317,3 +318,26 @@ export const useCors: RequestHandler = (req, res, next) => { }) return next() } + +/** + * Compress the given data using the specified `Content-Encoding` codings + * left-to-right. + */ +export function compressResponse( + codings: Array<'gzip' | 'x-gzip' | 'deflate' | 'br'>, + input: string +) { + let output = Buffer.from(input) + + for (const coding of codings) { + if (coding === 'gzip' || coding === 'x-gzip') { + output = zlib.gzipSync(output) + } else if (coding === 'deflate') { + output = zlib.deflateSync(output) + } else if (coding === 'br') { + output = zlib.brotliCompressSync(output) + } + } + + return output +} diff --git a/test/modules/fetch/compliance/response-content-encoding.browser.test.ts b/test/modules/fetch/compliance/response-content-encoding.browser.test.ts new file mode 100644 index 00000000..4c0f3c03 --- /dev/null +++ b/test/modules/fetch/compliance/response-content-encoding.browser.test.ts @@ -0,0 +1,190 @@ +import { HttpServer } from '@open-draft/test-server/http' +import { test, expect } from '../../../playwright.extend' +import { compressResponse, useCors } from '../../../helpers' +import { parseContentEncoding } from '../../../../src/interceptors/fetch/utils/decompression' +import { FetchInterceptor } from '../../../../src/interceptors/fetch' + +declare namespace window { + const interceptor: FetchInterceptor +} + +const server = new HttpServer((app) => { + app.use(useCors) + app.get('/resource', (req, res) => { + const acceptEncoding = req.header('x-accept-encoding') + const codings = parseContentEncoding(acceptEncoding || '') as any[] + + res + .set('content-encoding', acceptEncoding) + .end(compressResponse(codings, 'hello world')) + }) +}) + +test.beforeAll(async () => { + await server.listen() +}) + +test.afterAll(async () => { + await server.close() +}) + +test('decompresses a mocked "content-encoding: gzip" response body', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('../fetch.runtime.js')) + + await page.evaluate(() => { + window.interceptor.on('request', ({ controller }) => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('hello world')) + controller.close() + }, + }) + + return controller.respondWith( + new Response(stream.pipeThrough(new CompressionStream('gzip')), { + headers: { 'content-encoding': 'gzip' }, + }) + ) + }) + window.interceptor.apply() + }) + + const responseText = await page.evaluate(async (url) => { + const response = await fetch(url) + return response.text() + }, 'http://localhost/resource') + + expect(responseText).toBe('hello world') +}) + +test('decompresses a bypassed "content-encoding: gzip" response body', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('../fetch.runtime.js')) + + await page.evaluate(() => { + window.interceptor.apply() + }) + + const responseText = await page.evaluate(async (url) => { + const response = await fetch(url, { + /** + * @note `accept-encoding` is a forbidden browser header. + * Setting it will have no effect. Instead, rely on a custom header + * to communicate the expected encoding to the test server. + */ + headers: { 'x-accept-encoding': 'gzip' }, + }) + return response.text() + }, server.http.url('/resource')) + + expect(responseText).toBe('hello world') +}) + +test('decompresses a mocked "content-encoding: x-gzip" response body', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('../fetch.runtime.js')) + + await page.evaluate(() => { + window.interceptor.on('request', ({ controller }) => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('hello world')) + controller.close() + }, + }) + + return controller.respondWith( + new Response(stream.pipeThrough(new CompressionStream('gzip')), { + headers: { 'content-encoding': 'x-gzip' }, + }) + ) + }) + window.interceptor.apply() + }) + + const responseText = await page.evaluate(async (url) => { + const response = await fetch(url) + return response.text() + }, 'http://localhost/resource') + + expect(responseText).toBe('hello world') +}) + +test('decompresses a bypassed "content-encoding: x-gzip" response body', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('../fetch.runtime.js')) + + await page.evaluate(() => { + window.interceptor.apply() + }) + + const responseText = await page.evaluate(async (url) => { + const response = await fetch(url, { + headers: { 'x-accept-encoding': 'x-gzip' }, + }) + return response.text() + }, server.http.url('/resource')) + + expect(responseText).toBe('hello world') +}) + +test('decompresses a mocked "content-encoding: deflate" response body', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('../fetch.runtime.js')) + + await page.evaluate(() => { + window.interceptor.on('request', ({ controller }) => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('hello world')) + controller.close() + }, + }) + + return controller.respondWith( + new Response(stream.pipeThrough(new CompressionStream('deflate')), { + headers: { 'content-encoding': 'deflate' }, + }) + ) + }) + window.interceptor.apply() + }) + + const responseText = await page.evaluate(async (url) => { + const response = await fetch(url) + return response.text() + }, 'http://localhost/resource') + + expect(responseText).toBe('hello world') +}) + +test('decompresses a bypassed "content-encoding: deflate" response body', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('../fetch.runtime.js')) + + await page.evaluate(() => { + window.interceptor.apply() + }) + + const responseText = await page.evaluate(async (url) => { + const response = await fetch(url, { + headers: { 'x-accept-encoding': 'deflate' }, + }) + return response.text() + }, server.http.url('/resource')) + + expect(responseText).toBe('hello world') +}) diff --git a/test/modules/fetch/compliance/response-content-encoding.test.ts b/test/modules/fetch/compliance/response-content-encoding.test.ts new file mode 100644 index 00000000..f861c0f2 --- /dev/null +++ b/test/modules/fetch/compliance/response-content-encoding.test.ts @@ -0,0 +1,188 @@ +// @vitest-environment node +import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { HttpServer } from '@open-draft/test-server/http' +import { compressResponse } from '../../../helpers' +import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { parseContentEncoding } from '../../../../src/interceptors/fetch/utils/decompression' + +const httpServer = new HttpServer((app) => { + app.get('/compressed', (req, res) => { + const acceptEncoding = req.header('accept-encoding') + const codings = parseContentEncoding(acceptEncoding || '') as any[] + + res + .set('content-encoding', acceptEncoding) + .end(compressResponse(codings, 'hello world')) + }) +}) + +const interceptor = new FetchInterceptor() + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('decompresses a mocked "content-encoding: gzip" response body', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(compressResponse(['gzip'], 'hello world'), { + headers: { + 'content-encoding': 'gzip', + }, + }) + ) + }) + + const response = await fetch('http://localhost/resource') + expect(await response.text()).toBe('hello world') +}) + +it('decompresses a bypassed "content-encoding: gzip" response body', async () => { + const response = await fetch(httpServer.http.url('/compressed'), { + headers: { 'accept-encoding': 'gzip' }, + }) + expect(await response.text()).toBe('hello world') +}) + +it('decompresses a mocked "content-encoding: x-gzip" response body', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(compressResponse(['gzip'], 'hello world'), { + headers: { + 'content-encoding': 'x-gzip', + }, + }) + ) + }) + + const response = await fetch('http://localhost/resource') + expect(await response.text()).toBe('hello world') +}) + +it('decompresses a bypassed "content-encoding: x-gzip" response body', async () => { + const response = await fetch(httpServer.http.url('/compressed'), { + headers: { 'accept-encoding': 'x-gzip' }, + }) + expect(await response.text()).toBe('hello world') +}) + +it('decompresses a mocked "content-encoding: deflate" response body', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(compressResponse(['deflate'], 'hello world'), { + headers: { + 'content-encoding': 'deflate', + }, + }) + ) + }) + + const response = await fetch('http://localhost/resource') + expect(await response.text()).toBe('hello world') +}) + +it('decompresses a bypassed "content-encoding: deflate" response body', async () => { + const response = await fetch(httpServer.http.url('/compressed'), { + headers: { 'accept-encoding': 'deflate' }, + }) + expect(await response.text()).toBe('hello world') +}) + +it('decompresses a mocked "content-encoding: br" response body', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(compressResponse(['br'], 'hello world'), { + headers: { + 'content-encoding': 'br', + }, + }) + ) + }) + + const response = await fetch('http://localhost/resource') + expect(await response.text()).toBe('hello world') +}) + +it('decompresses a bypassed "content-encoding: br" response body', async () => { + const response = await fetch(httpServer.http.url('/compressed'), { + headers: { 'accept-encoding': 'br' }, + }) + expect(await response.text()).toBe('hello world') +}) + +it('decompresses a mocked "content-encoding: gzip, deflate" response body', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(compressResponse(['gzip', 'deflate'], 'hello world'), { + headers: { + 'content-encoding': 'gzip, deflate', + }, + }) + ) + }) + + const response = await fetch('http://localhost/resource') + expect(await response.text()).toBe('hello world') +}) + +/** + * Undici throws an error decompressing a "gzip, deflate" response. + * @see https://github.com/nodejs/undici/issues/3762 + */ +it.skip('decompresses a bypassed "content-encoding: gzip, deflate" response body', async () => { + const response = await fetch(httpServer.http.url('/compressed'), { + headers: { 'accept-encoding': 'gzip, deflate' }, + }) + expect(await response.text()).toBe('hello world') +}) + +it('decompresses a mocked "content-encoding: gzip, br" response body', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(compressResponse(['gzip', 'br'], 'hello world'), { + headers: { + 'content-encoding': 'gzip, br', + }, + }) + ) + }) + + const response = await fetch('http://localhost/resource') + expect(await response.text()).toBe('hello world') +}) + +/** + * Undici throws an error decompressing a "gzip, br" response. + * @see https://github.com/nodejs/undici/issues/3762 + */ +it.skip('decompresses a bypassed "content-encoding: gzip, br" response body', async () => { + const response = await fetch(httpServer.http.url('/compressed'), { + headers: { 'accept-encoding': 'gzip, br' }, + }) + expect(await response.text()).toBe('hello world') +}) + +it('throws error if decompression failed', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { + 'content-encoding': 'br', + }, + }) + ) + }) + + const response = await fetch('http://localhost/resource') + await expect(response.text()).rejects.toThrowError('Decompression failed') +}) diff --git a/test/modules/fetch/fetch.runtime.js b/test/modules/fetch/fetch.runtime.js new file mode 100644 index 00000000..ca277162 --- /dev/null +++ b/test/modules/fetch/fetch.runtime.js @@ -0,0 +1,4 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' + +const interceptor = new FetchInterceptor() +window.interceptor = interceptor diff --git a/test/vitest.config.js b/test/vitest.config.js index fba879b8..113b4fe6 100644 --- a/test/vitest.config.js +++ b/test/vitest.config.js @@ -10,4 +10,12 @@ export default defineConfig({ 'vitest-environment-react-native-like': './envs/react-native-like', }, }, + resolve: { + alias: { + // Create a manual alias for Vitest so it could resolve this + // internal environment-dependent module in tests. + 'internal:brotli-decompress': + '../../../../src/interceptors/fetch/utils/brotli-decompress.ts', + }, + }, }) diff --git a/tsconfig.json b/tsconfig.json index dd22a8e3..a653125f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,10 @@ "types": ["@types/node"], "baseUrl": ".", "paths": { - "_http_common": ["./_http_common.d.ts"] + "_http_common": ["./_http_common.d.ts"], + "internal:brotli-decompress": [ + "./src/interceptors/fetch/utils/brotli-decompress.ts" + ] } }, "include": ["src/**/*.ts"], diff --git a/tsup.config.ts b/tsup.config.ts index d56b251b..c0142dd3 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -14,6 +14,12 @@ const nodeConfig: Options = { format: ['cjs', 'esm'], sourcemap: true, dts: true, + esbuildOptions(options) { + options.alias = { + [`internal:brotli-decompress`]: + './src/interceptors/fetch/utils/brotli-decompress.ts', + } + }, } const browserConfig: Options = { @@ -29,6 +35,12 @@ const browserConfig: Options = { format: ['cjs', 'esm'], sourcemap: true, dts: true, + esbuildOptions(options) { + options.alias = { + [`internal:brotli-decompress`]: + './src/interceptors/fetch/utils/brotli-decompress.browser.ts', + } + }, } export default defineConfig([nodeConfig, browserConfig])