Skip to content

Commit

Permalink
fix(ClientRequest): use ServerResponse to build the HTTP response s…
Browse files Browse the repository at this point in the history
…tring (#596)

Co-authored-by: Artem Zakharchenko <kettanaito@gmail.com>
  • Loading branch information
mikicho and kettanaito authored Jul 6, 2024
1 parent 3a37d70 commit fedac45
Show file tree
Hide file tree
Showing 3 changed files with 82 additions and 43 deletions.
82 changes: 39 additions & 43 deletions src/interceptors/ClientRequest/MockHttpSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type RequestHeadersCompleteCallback,
type ResponseHeadersCompleteCallback,
} from '_http_common'
import { STATUS_CODES } from 'node:http'
import { IncomingMessage, ServerResponse } from 'node:http'
import { Readable } from 'node:stream'
import { invariant } from 'outvariant'
import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor'
Expand Down Expand Up @@ -272,36 +272,46 @@ export class MockHttpSocket extends MockSocket {
// if it hasn't been flushed already (e.g. someone started reading request stream).
this.flushWriteBuffer()

const httpHeaders: Array<Buffer> = []
// Create a `ServerResponse` instance to delegate HTTP message parsing,
// Transfer-Encoding, and other things to Node.js internals.
const serverResponse = new ServerResponse(new IncomingMessage(this))

httpHeaders.push(
Buffer.from(
`HTTP/1.1 ${response.status} ${
response.statusText || STATUS_CODES[response.status]
}\r\n`
)
/**
* Assign a mock socket instance to the server response to
* spy on the response chunk writes. Push the transformed response chunks
* to this `MockHttpSocket` instance to trigger the "data" event.
* @note Providing the same `MockSocket` instance when creating `ServerResponse`
* does not have the same effect.
* @see https://github.com/nodejs/node/blob/10099bb3f7fd97bb9dd9667188426866b3098e07/test/parallel/test-http-server-response-standalone.js#L32
*/
serverResponse.assignSocket(
new MockSocket({
write: (chunk, encoding, callback) => {
this.push(chunk, encoding)
callback?.()
},
read() {},
})
)
serverResponse.statusCode = response.status
serverResponse.statusMessage = response.statusText

/**
* @note Remove the `Connection` and `Date` response headers
* injected by `ServerResponse` by default. Those are required
* from the server but the interceptor is NOT technically a server.
* It's confusing to add response headers that the developer didn't
* specify themselves. They can always add these if they wish.
* @see https://www.rfc-editor.org/rfc/rfc9110#field.date
* @see https://www.rfc-editor.org/rfc/rfc9110#field.connection
*/
serverResponse.removeHeader('connection')
serverResponse.removeHeader('date')

// Get the raw headers stored behind the symbol to preserve name casing.
const headers = getRawFetchHeaders(response.headers) || response.headers
for (const [name, value] of headers) {
httpHeaders.push(Buffer.from(`${name}: ${value}\r\n`))
}

// An empty line separating headers from the body.
httpHeaders.push(Buffer.from('\r\n'))

const flushHeaders = (value?: Uint8Array) => {
if (httpHeaders.length === 0) {
return
}

if (typeof value !== 'undefined') {
httpHeaders.push(Buffer.from(value))
}

this.push(Buffer.concat(httpHeaders))
httpHeaders.length = 0
serverResponse.setHeader(name, value)
}

if (response.body) {
Expand All @@ -312,35 +322,21 @@ export class MockHttpSocket extends MockSocket {
const { done, value } = await reader.read()

if (done) {
serverResponse.end()
break
}

// Flush the headers upon the first chunk in the stream.
// This ensures the consumer will start receiving the response
// as it streams in (subsequent chunks are pushed).
if (httpHeaders.length > 0) {
flushHeaders(value)
continue
}

// Subsequent body chukns are push to the stream.
this.push(value)
serverResponse.write(value)
}
} catch (error) {
// Coerce response stream errors to 500 responses.
// Don't flush the original response headers because
// unhandled errors translate to 500 error responses forcefully.
this.respondWith(createServerErrorResponse(error))

return
}
} else {
serverResponse.end()
}

// If the headers were not flushed up to this point,
// this means the response either had no body or had
// an empty body stream. Flush the headers.
flushHeaders()

// Close the socket if the connection wasn't marked as keep-alive.
if (!this.shouldKeepAlive) {
this.emit('readable')
Expand Down
4 changes: 4 additions & 0 deletions test/modules/http/response/http-empty-response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,9 @@ it('supports responding with an empty mocked response', async () => {
const { res, text } = await waitForClientRequest(request)

expect(res.statusCode).toBe(200)
// Must not set any response headers that were not
// explicitly provided in the mocked response.
expect(res.headers).toEqual({})
expect(res.rawHeaders).toEqual([])
expect(await text()).toBe('')
})
39 changes: 39 additions & 0 deletions test/modules/http/response/http-response-transfer-encoding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @vitest-environment node
*/
import { it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import http from 'node:http'
import { waitForClientRequest } from '../../../helpers'
import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest'

const interceptor = new ClientRequestInterceptor()

beforeAll(() => {
interceptor.apply()
})

afterEach(() => {
interceptor.removeAllListeners()
})

afterAll(() => {
interceptor.dispose()
})

it('responds with a mocked "transfer-encoding: chunked" response', async () => {
interceptor.on('request', ({ request }) => {
request.respondWith(
new Response('mock', {
headers: { 'Transfer-Encoding': 'chunked' },
})
)
})

const request = http.get('http://localhost')
const { res, text } = await waitForClientRequest(request)

expect(res.statusCode).toBe(200)
expect(res.headers).toHaveProperty('transfer-encoding', 'chunked')
expect(res.rawHeaders).toContain('Transfer-Encoding')
expect(await text()).toBe('mock')
})

0 comments on commit fedac45

Please sign in to comment.