Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support non-configurable responses #677

Merged
merged 10 commits into from
Nov 15, 2024
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
Loading