Skip to content

Commit 6960356

Browse files
committed
feat: support RequestController with source
1 parent 329087a commit 6960356

File tree

17 files changed

+443
-348
lines changed

17 files changed

+443
-348
lines changed

src/RemoteHttpInterceptor.ts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { FetchInterceptor } from './interceptors/fetch'
88
import { handleRequest } from './utils/handleRequest'
99
import { RequestController } from './RequestController'
1010
import { FetchResponse } from './utils/fetchUtils'
11+
import { isResponseError } from './utils/responseUtils'
1112

1213
export interface SerializedRequest {
1314
id: string
@@ -178,13 +179,16 @@ export class RemoteHttpResolver extends Interceptor<HttpRequestEventMap> {
178179
body: requestJson.body,
179180
})
180181

181-
const controller = new RequestController(request)
182-
await handleRequest({
183-
request,
184-
requestId: requestJson.id,
185-
controller,
186-
emitter: this.emitter,
187-
onResponse: async (response) => {
182+
const controller = new RequestController(request, {
183+
passthrough: () => {
184+
//
185+
},
186+
respondWith: async (response) => {
187+
if (isResponseError(response)) {
188+
this.logger.info('received a network error!', { response })
189+
throw new Error('Not implemented')
190+
}
191+
188192
this.logger.info('received mocked response!', { response })
189193

190194
const responseClone = response.clone()
@@ -221,15 +225,18 @@ export class RemoteHttpResolver extends Interceptor<HttpRequestEventMap> {
221225
serializedResponse
222226
)
223227
},
224-
onRequestError: (response) => {
225-
this.logger.info('received a network error!', { response })
226-
throw new Error('Not implemented')
227-
},
228-
onError: (error) => {
229-
this.logger.info('request has errored!', { error })
228+
errorWith: (reason) => {
229+
this.logger.info('request has errored!', { error: reason })
230230
throw new Error('Not implemented')
231231
},
232232
})
233+
234+
await handleRequest({
235+
request,
236+
requestId: requestJson.id,
237+
controller,
238+
emitter: this.emitter,
239+
})
233240
}
234241

235242
this.subscriptions.push(() => {

src/RequestController.test.ts

Lines changed: 81 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,106 @@
1-
import { it, expect } from 'vitest'
2-
import { kResponsePromise, RequestController } from './RequestController'
1+
import { vi, it, expect } from 'vitest'
2+
import {
3+
RequestController,
4+
type RequestControllerSource,
5+
} from './RequestController'
6+
import { InterceptorError } from './InterceptorError'
37

4-
it('creates a pending response promise on construction', () => {
5-
const controller = new RequestController(new Request('http://localhost'))
6-
expect(controller[kResponsePromise]).toBeInstanceOf(Promise)
7-
expect(controller[kResponsePromise].state).toBe('pending')
8+
const defaultSource = {
9+
passthrough() {},
10+
respondWith() {},
11+
errorWith() {},
12+
} satisfies RequestControllerSource
13+
14+
it('has a pending state upon construction', () => {
15+
const controller = new RequestController(
16+
new Request('http://localhost'),
17+
defaultSource
18+
)
19+
20+
expect(controller.handled).toBeInstanceOf(Promise)
21+
expect(controller.readyState).toBe(RequestController.PENDING)
822
})
923

10-
it('resolves the response promise with the response provided to "respondWith"', async () => {
11-
const controller = new RequestController(new Request('http://localhost'))
12-
controller.respondWith(new Response('hello world'))
24+
it('handles a request when calling ".respondWith()" with a mocked response', async () => {
25+
const respondWith = vi.fn<RequestControllerSource['respondWith']>()
26+
const controller = new RequestController(new Request('http://localhost'), {
27+
...defaultSource,
28+
respondWith,
29+
})
1330

14-
const response = (await controller[kResponsePromise]) as Response
31+
await controller.respondWith(new Response('hello world'))
32+
33+
expect(controller.readyState).toBe(RequestController.RESPONSE)
34+
await expect(controller.handled).resolves.toBeUndefined()
35+
36+
expect(respondWith).toHaveBeenCalledOnce()
37+
const [response] = respondWith.mock.calls[0]
1538

1639
expect(response).toBeInstanceOf(Response)
1740
expect(response.status).toBe(200)
18-
expect(await response.text()).toBe('hello world')
41+
await expect(response.text()).resolves.toBe('hello world')
1942
})
2043

21-
it('resolves the response promise with the error provided to "errorWith"', async () => {
22-
const controller = new RequestController(new Request('http://localhost'))
44+
it('handles the request when calling ".errorWith()" with an error', async () => {
45+
const errorWith = vi.fn<RequestControllerSource['errorWith']>()
46+
const controller = new RequestController(new Request('http://localhost'), {
47+
...defaultSource,
48+
errorWith,
49+
})
50+
2351
const error = new Error('Oops!')
24-
controller.errorWith(error)
52+
await controller.errorWith(error)
2553

26-
await expect(controller[kResponsePromise]).resolves.toEqual(error)
54+
expect(controller.readyState).toBe(RequestController.ERROR)
55+
await expect(controller.handled).resolves.toBeUndefined()
56+
57+
expect(errorWith).toHaveBeenCalledOnce()
58+
expect(errorWith).toHaveBeenCalledWith(error)
2759
})
2860

29-
it('resolves the response promise with an arbitrary object provided to "errorWith"', async () => {
30-
const controller = new RequestController(new Request('http://localhost'))
61+
it('handles the request when calling ".errorWith()" with an arbitrary object', async () => {
62+
const errorWith = vi.fn<RequestControllerSource['errorWith']>()
63+
const controller = new RequestController(new Request('http://localhost'), {
64+
...defaultSource,
65+
errorWith,
66+
})
67+
3168
const error = { message: 'Oops!' }
32-
controller.errorWith(error)
69+
await controller.errorWith(error)
70+
71+
expect(controller.readyState).toBe(RequestController.ERROR)
72+
await expect(controller.handled).resolves.toBeUndefined()
3373

34-
await expect(controller[kResponsePromise]).resolves.toEqual(error)
74+
expect(errorWith).toHaveBeenCalledOnce()
75+
expect(errorWith).toHaveBeenCalledWith(error)
3576
})
3677

37-
it('throws when calling "respondWith" multiple times', () => {
38-
const controller = new RequestController(new Request('http://localhost'))
39-
controller.respondWith(new Response('hello world'))
78+
it('throws when calling "respondWith" multiple times', async () => {
79+
const controller = new RequestController(
80+
new Request('http://localhost'),
81+
defaultSource
82+
)
83+
await controller.respondWith(new Response('hello world'))
4084

41-
expect(() => {
85+
await expect(
4286
controller.respondWith(new Response('second response'))
43-
}).toThrow(
44-
'Failed to respond to the "GET http://localhost/" request: the "request" event has already been handled.'
87+
).rejects.toThrow(
88+
new InterceptorError(
89+
'Failed to respond to the "GET http://localhost/" request: the request has already been handled'
90+
)
4591
)
4692
})
4793

48-
it('throws when calling "errorWith" multiple times', () => {
49-
const controller = new RequestController(new Request('http://localhost'))
50-
controller.errorWith(new Error('Oops!'))
94+
it('throws when calling "errorWith" multiple times', async () => {
95+
const controller = new RequestController(
96+
new Request('http://localhost'),
97+
defaultSource
98+
)
99+
await controller.errorWith(new Error('Oops!'))
51100

52-
expect(() => {
53-
controller.errorWith(new Error('second error'))
54-
}).toThrow(
55-
'Failed to error the "GET http://localhost/" request: the "request" event has already been handled.'
101+
await expect(controller.errorWith(new Error('second error'))).rejects.toThrow(
102+
new InterceptorError(
103+
'Failed to error the "GET http://localhost/" request: the request has already been handled'
104+
)
56105
)
57106
})

src/RequestController.ts

Lines changed: 63 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,59 @@
1-
import { invariant } from 'outvariant'
21
import { DeferredPromise } from '@open-draft/deferred-promise'
2+
import { invariant } from 'outvariant'
33
import { InterceptorError } from './InterceptorError'
44

5-
const kRequestHandled = Symbol('kRequestHandled')
6-
export const kResponsePromise = Symbol('kResponsePromise')
5+
export interface RequestControllerSource {
6+
passthrough(): void
7+
respondWith(response: Response): void
8+
errorWith(reason?: unknown): void
9+
}
710

811
export class RequestController {
12+
static PENDING = 0 as const
13+
static PASSTHROUGH = 1 as const
14+
static RESPONSE = 2 as const
15+
static ERROR = 3 as const
16+
17+
public readyState: number
18+
919
/**
10-
* Internal response promise.
11-
* Available only for the library internals to grab the
12-
* response instance provided by the developer.
13-
* @note This promise cannot be rejected. It's either infinitely
14-
* pending or resolved with whichever Response was passed to `respondWith()`.
20+
* A Promise that resolves when this controller handles a request.
21+
* See `controller.readyState` for more information on the handling result.
1522
*/
16-
[kResponsePromise]: DeferredPromise<
17-
Response | Record<string, any> | undefined
18-
>;
23+
public handled: Promise<void>
24+
25+
constructor(
26+
protected readonly request: Request,
27+
protected readonly source: RequestControllerSource
28+
) {
29+
this.readyState = RequestController.PENDING
30+
this.handled = new DeferredPromise<void>()
31+
}
32+
33+
get #handled() {
34+
return this.handled as DeferredPromise<void>
35+
}
1936

2037
/**
21-
* Internal flag indicating if this request has been handled.
22-
* @note The response promise becomes "fulfilled" on the next tick.
38+
* Perform this request as-is.
2339
*/
24-
[kRequestHandled]: boolean
40+
public async passthrough(): Promise<void> {
41+
invariant.as(
42+
InterceptorError,
43+
this.readyState === RequestController.PENDING,
44+
'Failed to passthrough the "%s %s" request: the request has already been handled',
45+
this.request.method,
46+
this.request.url
47+
)
2548

26-
constructor(private request: Request) {
27-
this[kRequestHandled] = false
28-
this[kResponsePromise] = new DeferredPromise()
49+
this.readyState = RequestController.PASSTHROUGH
50+
await this.source.passthrough()
51+
this.#handled.resolve()
2952
}
3053

3154
/**
3255
* Respond to this request with the given `Response` instance.
56+
*
3357
* @example
3458
* controller.respondWith(new Response())
3559
* controller.respondWith(Response.json({ id }))
@@ -38,22 +62,25 @@ export class RequestController {
3862
public respondWith(response: Response): void {
3963
invariant.as(
4064
InterceptorError,
41-
!this[kRequestHandled],
42-
'Failed to respond to the "%s %s" request: the "request" event has already been handled.',
65+
this.readyState === RequestController.PENDING,
66+
'Failed to respond to the "%s %s" request with "%d %s": the request has already been handled (%d)',
4367
this.request.method,
44-
this.request.url
68+
this.request.url,
69+
response.status,
70+
response.statusText || 'OK',
71+
this.readyState
4572
)
4673

47-
this[kRequestHandled] = true
48-
this[kResponsePromise].resolve(response)
74+
this.readyState = RequestController.RESPONSE
75+
this.#handled.resolve()
4976

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

5986
/**
@@ -64,22 +91,19 @@ export class RequestController {
6491
* controller.errorWith(new Error('Oops!'))
6592
* controller.errorWith({ message: 'Oops!'})
6693
*/
67-
public errorWith(reason?: Error | Record<string, any>): void {
94+
public errorWith(reason?: unknown): void {
6895
invariant.as(
6996
InterceptorError,
70-
!this[kRequestHandled],
71-
'Failed to error the "%s %s" request: the "request" event has already been handled.',
97+
this.readyState === RequestController.PENDING,
98+
'Failed to error the "%s %s" request with "%s": the request has already been handled (%d)',
7299
this.request.method,
73-
this.request.url
100+
this.request.url,
101+
reason?.toString(),
102+
this.readyState
74103
)
75104

76-
this[kRequestHandled] = true
77-
78-
/**
79-
* @note Resolve the response promise, not reject.
80-
* This helps us differentiate between unhandled exceptions
81-
* and intended errors ("errorWith") while waiting for the response.
82-
*/
83-
this[kResponsePromise].resolve(reason)
105+
this.readyState = RequestController.ERROR
106+
this.source.errorWith(reason)
107+
this.#handled.resolve()
84108
}
85109
}

src/interceptors/ClientRequest/MockHttpSocket.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,16 @@ export class MockHttpSocket extends MockSocket {
311311
return
312312
}
313313

314+
// Prevent recursive calls.
315+
invariant(
316+
this.socketState !== 'mock',
317+
'[MockHttpSocket] Failed to respond to the "%s %s" request with "%s %s": the request has already been handled',
318+
this.request?.method,
319+
this.request?.url,
320+
response.status,
321+
response.statusText
322+
)
323+
314324
// Handle "type: error" responses.
315325
if (isPropertyAccessible(response, 'type') && response.type === 'error') {
316326
this.errorWith(new TypeError('Network error'))
@@ -393,9 +403,18 @@ export class MockHttpSocket extends MockSocket {
393403
serverResponse.write(value)
394404
}
395405
} catch (error) {
396-
// Coerce response stream errors to 500 responses.
397-
this.respondWith(createServerErrorResponse(error))
398-
return
406+
if (error instanceof Error) {
407+
serverResponse.destroy()
408+
/**
409+
* @note Destroy the request socket gracefully.
410+
* Response stream errors do NOT produce request errors.
411+
*/
412+
this.destroy()
413+
return
414+
}
415+
416+
serverResponse.destroy()
417+
throw error
399418
}
400419
} else {
401420
serverResponse.end()

0 commit comments

Comments
 (0)