Skip to content

Commit

Permalink
feat: support non-configurable responses (#677)
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito authored Nov 15, 2024
1 parent 21c095a commit 1d1cc09
Show file tree
Hide file tree
Showing 10 changed files with 326 additions and 48 deletions.
4 changes: 3 additions & 1 deletion src/RemoteHttpInterceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ClientRequestInterceptor } from './interceptors/ClientRequest'
import { XMLHttpRequestInterceptor } from './interceptors/XMLHttpRequest'
import { handleRequest } from './utils/handleRequest'
import { RequestController } from './RequestController'
import { FetchResponse } from './utils/fetchUtils'

export interface SerializedRequest {
id: string
Expand Down Expand Up @@ -85,7 +86,8 @@ export class RemoteHttpInterceptor extends BatchInterceptor<
serializedResponse
) as SerializedResponse

const mockedResponse = new Response(responseInit.body, {
const mockedResponse = new FetchResponse(responseInit.body, {
url: request.url,
status: responseInit.status,
statusText: responseInit.statusText,
headers: responseInit.headers,
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ export * from './BatchInterceptor'
export { createRequestId } from './createRequestId'
export { getCleanUrl } from './utils/getCleanUrl'
export { encodeBuffer, decodeBuffer } from './utils/bufferUtils'
export { isResponseWithoutBody } from './utils/responseUtils'
export { FetchResponse } from './utils/fetchUtils'
21 changes: 9 additions & 12 deletions src/interceptors/ClientRequest/MockHttpSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,10 @@ import type { NormalizedSocketWriteArgs } from '../Socket/utils/normalizeSocketW
import { isPropertyAccessible } from '../../utils/isPropertyAccessible'
import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions'
import { parseRawHeaders } from '../Socket/utils/parseRawHeaders'
import {
createServerErrorResponse,
RESPONSE_STATUS_CODES_WITHOUT_BODY,
} from '../../utils/responseUtils'
import { createServerErrorResponse } from '../../utils/responseUtils'
import { createRequestId } from '../../createRequestId'
import { getRawFetchHeaders } from './utils/recordRawHeaders'
import { FetchResponse } from '../../utils/fetchUtils'

type HttpConnectionOptions = any

Expand Down Expand Up @@ -541,23 +539,22 @@ export class MockHttpSocket extends MockSocket {
statusText
) => {
const headers = parseRawHeaders(rawHeaders)
const canHaveBody = !RESPONSE_STATUS_CODES_WITHOUT_BODY.has(status)

// Similarly, create a new stream for each response.
if (canHaveBody) {
this.responseStream = new Readable({ read() {} })
}

const response = new Response(
const response = new FetchResponse(
/**
* @note The Fetch API response instance exposed to the consumer
* is created over the response stream of the HTTP parser. It is NOT
* related to the Socket instance. This way, you can read response body
* in response listener while the Socket instance delays the emission
* of "end" and other events until those response listeners are finished.
*/
canHaveBody ? (Readable.toWeb(this.responseStream!) as any) : null,
FetchResponse.isResponseWithBody(status)
? (Readable.toWeb(
(this.responseStream = new Readable({ read() {} }))
) as any)
: null,
{
url,
status,
statusText,
headers,
Expand Down
9 changes: 6 additions & 3 deletions src/interceptors/XMLHttpRequest/utils/createResponse.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isResponseWithoutBody } from '../../../utils/responseUtils'
import { FetchResponse } from '../../../utils/fetchUtils'

/**
* Creates a Fetch API `Response` instance from the given
Expand All @@ -16,9 +16,12 @@ export function createResponse(
* when constructing a Response instance.
* @see https://github.com/mswjs/interceptors/issues/379
*/
const responseBodyOrNull = isResponseWithoutBody(request.status) ? null : body
const responseBodyOrNull = FetchResponse.isResponseWithBody(request.status)
? body
: null

return new Response(responseBodyOrNull, {
return new FetchResponse(responseBodyOrNull, {
url: request.responseURL,
status: request.status,
statusText: request.statusText,
headers: createHeadersFromXMLHttpReqestHeaders(
Expand Down
16 changes: 5 additions & 11 deletions src/interceptors/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import { emitAsync } from '../../utils/emitAsync'
import { handleRequest } from '../../utils/handleRequest'
import { canParseUrl } from '../../utils/canParseUrl'
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'
import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal'
import { FetchResponse } from '../../utils/fetchUtils'

export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
static symbol = Symbol('fetch')
Expand Down Expand Up @@ -75,15 +75,17 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
const response =
decompressedStream === null
? rawResponse
: new Response(decompressedStream, rawResponse)
: new FetchResponse(decompressedStream, rawResponse)

FetchResponse.setUrl(request.url, response)

/**
* Undici's handling of following redirect responses.
* Treat the "manual" redirect mode as a regular mocked response.
* This way, the client can manually follow the redirect it receives.
* @see https://github.com/nodejs/undici/blob/a6dac3149c505b58d2e6d068b97f4dc993da55f0/lib/web/fetch/index.js#L1173
*/
if (RESPONSE_STATUS_CODES_WITH_REDIRECT.has(response.status)) {
if (FetchResponse.isRedirectResponse(response.status)) {
// Reject the request promise if its `redirect` is set to `error`
// and it receives a mocked redirect response.
if (request.redirect === 'error') {
Expand All @@ -104,14 +106,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,
})

if (this.emitter.listenerCount('response') > 0) {
this.logger.info('emitting the "response" event...')

Expand Down
82 changes: 82 additions & 0 deletions src/utils/fetchUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
export interface FetchResponseInit extends ResponseInit {
url?: string
}

export class FetchResponse extends Response {
/**
* Response status codes for responses that cannot have body.
* @see https://fetch.spec.whatwg.org/#statuses
*/
static readonly STATUS_CODES_WITHOUT_BODY = [101, 103, 204, 205, 304]

static readonly STATUS_CODES_WITH_REDIRECT = [301, 302, 303, 307, 308]

static isConfigurableStatusCode(status: number): boolean {
return status >= 200 && status <= 599
}

static isRedirectResponse(status: number): boolean {
return FetchResponse.STATUS_CODES_WITH_REDIRECT.includes(status)
}

/**
* Returns a boolean indicating whether the given response status
* code represents a response that can have a body.
*/
static isResponseWithBody(status: number): boolean {
return !FetchResponse.STATUS_CODES_WITHOUT_BODY.includes(status)
}

static setUrl(url: string | undefined, response: Response): void {
if (!url) {
return
}

if (response.url != '') {
return
}

Object.defineProperty(response, 'url', {
value: url,
enumerable: true,
configurable: true,
writable: false,
})
}

constructor(body?: BodyInit | null, init: FetchResponseInit = {}) {
const status = init.status ?? 200
const safeStatus = FetchResponse.isConfigurableStatusCode(status)
? status
: 200
const finalBody = FetchResponse.isResponseWithBody(status) ? body : null

super(finalBody, {
...init,
status: safeStatus,
})

if (status !== safeStatus) {
/**
* @note Undici keeps an internal "Symbol(state)" that holds
* the actual value of response status. Update that in Node.js.
*/
const stateSymbol = Object.getOwnPropertySymbols(this).find(
(symbol) => symbol.description === 'state'
)
if (stateSymbol) {
const state = Reflect.get(this, stateSymbol) as object
Reflect.set(state, 'status', status)
} else {
Object.defineProperty(this, 'status', {
value: status,
enumerable: true,
configurable: true,
writable: false,
})
}
}

FetchResponse.setUrl(init.url, this)
}
}
20 changes: 0 additions & 20 deletions src/utils/responseUtils.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,5 @@
import { isPropertyAccessible } from './isPropertyAccessible'

/**
* Response status codes for responses that cannot have body.
* @see https://fetch.spec.whatwg.org/#statuses
*/
export const RESPONSE_STATUS_CODES_WITHOUT_BODY = new Set([
101, 103, 204, 205, 304,
])

export const RESPONSE_STATUS_CODES_WITH_REDIRECT = new Set([
301, 302, 303, 307, 308,
])

/**
* Returns a boolean indicating whether the given response status
* code represents a response that cannot have a body.
*/
export function isResponseWithoutBody(status: number): boolean {
return RESPONSE_STATUS_CODES_WITHOUT_BODY.has(status)
}

/**
* Creates a generic 500 Unhandled Exception response.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// @vitest-environment jsdom
/**
* @see https://github.com/mswjs/msw/issues/2307
*/
import { it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import { HttpServer } from '@open-draft/test-server/http'
import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest'
import { FetchResponse } from '../../../../src/utils/fetchUtils'
import { createXMLHttpRequest, useCors } from '../../../helpers'
import { DeferredPromise } from '@open-draft/deferred-promise'

const interceptor = new XMLHttpRequestInterceptor()

const httpServer = new HttpServer((app) => {
app.use(useCors)
app.get('/resource', (_req, res) => {
res.writeHead(101, 'Switching Protocols')
res.end()
})
})

beforeAll(async () => {
interceptor.apply()
await httpServer.listen()
})

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

afterAll(async () => {
interceptor.dispose()
await httpServer.close()
})

it('handles non-configurable responses from the actual server', async () => {
const responsePromise = new DeferredPromise<Response>()
interceptor.on('response', ({ response }) => {
responsePromise.resolve(response)
})

const request = await createXMLHttpRequest((request) => {
request.open('GET', httpServer.http.url('/resource'))
request.send()
})

expect(request.status).toBe(101)
expect(request.statusText).toBe('Switching Protocols')
expect(request.responseText).toBe('')

// Must expose the exact response in the listener.
await expect(responsePromise).resolves.toHaveProperty('status', 101)
})

it('supports mocking non-configurable responses', async () => {
interceptor.on('request', ({ controller }) => {
/**
* @note The Fetch API `Response` will still error on
* non-configurable status codes. Instead, use this helper class.
*/
controller.respondWith(new FetchResponse(null, { status: 101 }))
})

const responsePromise = new DeferredPromise<Response>()
interceptor.on('response', ({ response }) => {
responsePromise.resolve(response)
})

const request = await createXMLHttpRequest((request) => {
request.open('GET', httpServer.http.url('/resource'))
request.send()
})

expect(request.status).toBe(101)
expect(request.responseText).toBe('')

// Must expose the exact response in the listener.
await expect(responsePromise).resolves.toHaveProperty('status', 101)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// @vitest-environment node
import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import { HttpServer } from '@open-draft/test-server/http'
import { DeferredPromise } from '@open-draft/deferred-promise'
import { FetchInterceptor } from '../../../../src/interceptors/fetch'
import { FetchResponse } from '../../../../src/utils/fetchUtils'

const interceptor = new FetchInterceptor()

const httpServer = new HttpServer((app) => {
app.get('/resource', (_req, res) => {
res.writeHead(101, 'Switching Protocols')
res.set('connection', 'upgrade')
res.set('upgrade', 'HTTP/2.0')
res.end()
})
})

beforeAll(async () => {
interceptor.apply()
await httpServer.listen()
})

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

afterAll(async () => {
interceptor.dispose()
await httpServer.close()
})

it('handles non-configurable responses from the actual server', async () => {
const responseListener = vi.fn()
interceptor.on('response', responseListener)

// Fetch doesn't handle 101 responses by spec.
await expect(fetch(httpServer.http.url('/resource'))).rejects.toThrow(
'fetch failed'
)

// Must not call the response listner. Fetch failed.
expect(responseListener).not.toHaveBeenCalled()
})

it('supports mocking non-configurable responses', async () => {
interceptor.on('request', ({ controller }) => {
/**
* @note The Fetch API `Response` will still error on
* non-configurable status codes. Instead, use this helper class.
*/
controller.respondWith(new FetchResponse(null, { status: 101 }))
})

const responsePromise = new DeferredPromise<Response>()
interceptor.on('response', ({ response }) => {
responsePromise.resolve(response)
})

const response = await fetch('http://localhost/irrelevant')

expect(response.status).toBe(101)

// Must expose the exact response in the listener.
await expect(responsePromise).resolves.toHaveProperty('status', 101)
})
Loading

0 comments on commit 1d1cc09

Please sign in to comment.