Skip to content

Commit

Permalink
feat: use compression streams to decompress responses
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito committed Oct 16, 2024
1 parent cdfdc95 commit d4d4939
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 16 deletions.
28 changes: 18 additions & 10 deletions src/interceptors/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpRequestEventMap> {
static symbol = Symbol('fetch')
Expand Down Expand Up @@ -66,11 +67,18 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
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.
Expand Down Expand Up @@ -98,6 +106,14 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
}
}

// 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...')

Expand All @@ -115,14 +131,6 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
})
}

// 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) => {
Expand Down
94 changes: 94 additions & 0 deletions src/interceptors/fetch/utils/compression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
function pipeline(streams: Array<TransformStream>): 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<TransformStream> = []

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<any> | 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
}
36 changes: 30 additions & 6 deletions test/modules/fetch/compliance/response-content-encoding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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')
})

0 comments on commit d4d4939

Please sign in to comment.