Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 18 additions & 13 deletions src/RemoteHttpInterceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { FetchInterceptor } from './interceptors/fetch'
import { handleRequest } from './utils/handleRequest'
import { RequestController } from './RequestController'
import { FetchResponse } from './utils/fetchUtils'
import { isResponseError } from './utils/responseUtils'

export interface SerializedRequest {
id: string
Expand Down Expand Up @@ -178,13 +179,14 @@ export class RemoteHttpResolver extends Interceptor<HttpRequestEventMap> {
body: requestJson.body,
})

const controller = new RequestController(request)
await handleRequest({
request,
requestId: requestJson.id,
controller,
emitter: this.emitter,
onResponse: async (response) => {
const controller = new RequestController(request, {
passthrough: () => {},
respondWith: async (response) => {
if (isResponseError(response)) {
this.logger.info('received a network error!', { response })
throw new Error('Not implemented')
}

this.logger.info('received mocked response!', { response })

const responseClone = response.clone()
Expand Down Expand Up @@ -221,15 +223,18 @@ export class RemoteHttpResolver extends Interceptor<HttpRequestEventMap> {
serializedResponse
)
},
onRequestError: (response) => {
this.logger.info('received a network error!', { response })
throw new Error('Not implemented')
},
onError: (error) => {
this.logger.info('request has errored!', { error })
errorWith: (reason) => {
this.logger.info('request has errored!', { error: reason })
throw new Error('Not implemented')
},
})

await handleRequest({
request,
requestId: requestJson.id,
controller,
emitter: this.emitter,
})
}

this.subscriptions.push(() => {
Expand Down
109 changes: 78 additions & 31 deletions src/RequestController.test.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,104 @@
import { it, expect } from 'vitest'
import { kResponsePromise, RequestController } from './RequestController'
import { vi, it, expect } from 'vitest'
import {
RequestController,
type RequestControllerSource,
} from './RequestController'
import { InterceptorError } from './InterceptorError'

it('creates a pending response promise on construction', () => {
const controller = new RequestController(new Request('http://localhost'))
expect(controller[kResponsePromise]).toBeInstanceOf(Promise)
expect(controller[kResponsePromise].state).toBe('pending')
const defaultSource = {
passthrough() {},
respondWith() {},
errorWith() {},
} satisfies RequestControllerSource

it('has a pending state upon construction', () => {
const controller = new RequestController(
new Request('http://localhost'),
defaultSource
)

expect(controller.handled).toBeInstanceOf(Promise)
expect(controller.readyState).toBe(RequestController.PENDING)
})

it('resolves the response promise with the response provided to "respondWith"', async () => {
const controller = new RequestController(new Request('http://localhost'))
controller.respondWith(new Response('hello world'))
it('handles a request when calling ".respondWith()" with a mocked response', async () => {
const respondWith = vi.fn<RequestControllerSource['respondWith']>()
const controller = new RequestController(new Request('http://localhost'), {
...defaultSource,
respondWith,
})

await controller.respondWith(new Response('hello world'))

expect(controller.readyState).toBe(RequestController.RESPONSE)
await expect(controller.handled).resolves.toBeUndefined()

const response = (await controller[kResponsePromise]) as Response
expect(respondWith).toHaveBeenCalledOnce()
const [response] = respondWith.mock.calls[0]

expect(response).toBeInstanceOf(Response)
expect(response.status).toBe(200)
expect(await response.text()).toBe('hello world')
await expect(response.text()).resolves.toBe('hello world')
})

it('resolves the response promise with the error provided to "errorWith"', async () => {
const controller = new RequestController(new Request('http://localhost'))
it('handles the request when calling ".errorWith()" with an error', async () => {
const errorWith = vi.fn<RequestControllerSource['errorWith']>()
const controller = new RequestController(new Request('http://localhost'), {
...defaultSource,
errorWith,
})

const error = new Error('Oops!')
controller.errorWith(error)
await controller.errorWith(error)

expect(controller.readyState).toBe(RequestController.ERROR)
await expect(controller.handled).resolves.toBeUndefined()

await expect(controller[kResponsePromise]).resolves.toEqual(error)
expect(errorWith).toHaveBeenCalledOnce()
expect(errorWith).toHaveBeenCalledWith(error)
})

it('resolves the response promise with an arbitrary object provided to "errorWith"', async () => {
const controller = new RequestController(new Request('http://localhost'))
it('handles the request when calling ".errorWith()" with an arbitrary object', async () => {
const errorWith = vi.fn<RequestControllerSource['errorWith']>()
const controller = new RequestController(new Request('http://localhost'), {
...defaultSource,
errorWith,
})

const error = { message: 'Oops!' }
controller.errorWith(error)
await controller.errorWith(error)

await expect(controller[kResponsePromise]).resolves.toEqual(error)
expect(controller.readyState).toBe(RequestController.ERROR)
await expect(controller.handled).resolves.toBeUndefined()

expect(errorWith).toHaveBeenCalledOnce()
expect(errorWith).toHaveBeenCalledWith(error)
})

it('throws when calling "respondWith" multiple times', () => {
const controller = new RequestController(new Request('http://localhost'))
it('throws when calling "respondWith" multiple times', async () => {
const controller = new RequestController(
new Request('http://localhost'),
defaultSource
)
controller.respondWith(new Response('hello world'))

expect(() => {
controller.respondWith(new Response('second response'))
}).toThrow(
'Failed to respond to the "GET http://localhost/" request: the "request" event has already been handled.'
expect(() => controller.respondWith(new Response('second response'))).toThrow(
new InterceptorError(
'Failed to respond to the "GET http://localhost/" request with "200 OK": the request has already been handled (2)'
)
)
})

it('throws when calling "errorWith" multiple times', () => {
const controller = new RequestController(new Request('http://localhost'))
it('throws when calling "errorWith" multiple times', async () => {
const controller = new RequestController(
new Request('http://localhost'),
defaultSource
)
controller.errorWith(new Error('Oops!'))

expect(() => {
controller.errorWith(new Error('second error'))
}).toThrow(
'Failed to error the "GET http://localhost/" request: the "request" event has already been handled.'
expect(() => controller.errorWith(new Error('second error'))).toThrow(
new InterceptorError(
'Failed to error the "GET http://localhost/" request with "Error: second error": the request has already been handled (3)'
)
)
})
102 changes: 63 additions & 39 deletions src/RequestController.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,59 @@
import { invariant } from 'outvariant'
import { DeferredPromise } from '@open-draft/deferred-promise'
import { invariant } from 'outvariant'
import { InterceptorError } from './InterceptorError'

const kRequestHandled = Symbol('kRequestHandled')
export const kResponsePromise = Symbol('kResponsePromise')
export interface RequestControllerSource {
passthrough(): void
respondWith(response: Response): void
errorWith(reason?: unknown): void
}

export class RequestController {
static PENDING = 0 as const
static PASSTHROUGH = 1 as const
static RESPONSE = 2 as const
static ERROR = 3 as const

public readyState: number

/**
* Internal response promise.
* Available only for the library internals to grab the
* response instance provided by the developer.
* @note This promise cannot be rejected. It's either infinitely
* pending or resolved with whichever Response was passed to `respondWith()`.
* A Promise that resolves when this controller handles a request.
* See `controller.readyState` for more information on the handling result.
*/
[kResponsePromise]: DeferredPromise<
Response | Record<string, any> | undefined
>;
public handled: Promise<void>

constructor(
protected readonly request: Request,
protected readonly source: RequestControllerSource
) {
this.readyState = RequestController.PENDING
this.handled = new DeferredPromise<void>()
}

get #handled() {
return this.handled as DeferredPromise<void>
}

/**
* Internal flag indicating if this request has been handled.
* @note The response promise becomes "fulfilled" on the next tick.
* Perform this request as-is.
*/
[kRequestHandled]: boolean
public async passthrough(): Promise<void> {
invariant.as(
InterceptorError,
this.readyState === RequestController.PENDING,
'Failed to passthrough the "%s %s" request: the request has already been handled',
this.request.method,
this.request.url
)

constructor(private request: Request) {
this[kRequestHandled] = false
this[kResponsePromise] = new DeferredPromise()
this.readyState = RequestController.PASSTHROUGH
await this.source.passthrough()
this.#handled.resolve()
}

/**
* Respond to this request with the given `Response` instance.
*
* @example
* controller.respondWith(new Response())
* controller.respondWith(Response.json({ id }))
Expand All @@ -38,22 +62,25 @@ export class RequestController {
public respondWith(response: Response): void {
invariant.as(
InterceptorError,
!this[kRequestHandled],
'Failed to respond to the "%s %s" request: the "request" event has already been handled.',
this.readyState === RequestController.PENDING,
'Failed to respond to the "%s %s" request with "%d %s": the request has already been handled (%d)',
this.request.method,
this.request.url
this.request.url,
response.status,
response.statusText || 'OK',
this.readyState
)

this[kRequestHandled] = true
this[kResponsePromise].resolve(response)
this.readyState = RequestController.RESPONSE
this.#handled.resolve()

/**
* @note The request controller doesn't do anything
* apart from letting the interceptor await the response
* provided by the developer through the response promise.
* Each interceptor implements the actual respondWith/errorWith
* logic based on that interceptor's needs.
* @note Although `source.respondWith()` is potentially asynchronous,
* do NOT await it for backward-compatibility. Awaiting it will short-circuit
* the request listener invocation as soon as a listener responds to a request.
* Ideally, that's what we want, but that's not what we promise the user.
*/
this.source.respondWith(response)
}

/**
Expand All @@ -64,22 +91,19 @@ export class RequestController {
* controller.errorWith(new Error('Oops!'))
* controller.errorWith({ message: 'Oops!'})
*/
public errorWith(reason?: Error | Record<string, any>): void {
public errorWith(reason?: unknown): void {
invariant.as(
InterceptorError,
!this[kRequestHandled],
'Failed to error the "%s %s" request: the "request" event has already been handled.',
this.readyState === RequestController.PENDING,
'Failed to error the "%s %s" request with "%s": the request has already been handled (%d)',
this.request.method,
this.request.url
this.request.url,
reason?.toString(),
this.readyState
)

this[kRequestHandled] = true

/**
* @note Resolve the response promise, not reject.
* This helps us differentiate between unhandled exceptions
* and intended errors ("errorWith") while waiting for the response.
*/
this[kResponsePromise].resolve(reason)
this.readyState = RequestController.ERROR
this.source.errorWith(reason)
this.#handled.resolve()
}
}
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
export * from './glossary'
export * from './Interceptor'
export * from './BatchInterceptor'
export {
RequestController,
type RequestControllerSource,
} from './RequestController'

/* Utils */
export { createRequestId } from './createRequestId'
Expand Down
25 changes: 22 additions & 3 deletions src/interceptors/ClientRequest/MockHttpSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,16 @@ export class MockHttpSocket extends MockSocket {
return
}

// Prevent recursive calls.
invariant(
this.socketState !== 'mock',
'[MockHttpSocket] Failed to respond to the "%s %s" request with "%s %s": the request has already been handled',
this.request?.method,
this.request?.url,
response.status,
response.statusText
)

// Handle "type: error" responses.
if (isPropertyAccessible(response, 'type') && response.type === 'error') {
this.errorWith(new TypeError('Network error'))
Expand Down Expand Up @@ -393,9 +403,18 @@ export class MockHttpSocket extends MockSocket {
serverResponse.write(value)
}
} catch (error) {
// Coerce response stream errors to 500 responses.
this.respondWith(createServerErrorResponse(error))
return
if (error instanceof Error) {
serverResponse.destroy()
/**
* @note Destroy the request socket gracefully.
* Response stream errors do NOT produce request errors.
*/
this.destroy()
return
}

serverResponse.destroy()
throw error
}
} else {
serverResponse.end()
Expand Down
Loading