Skip to content

Commit

Permalink
fix: cache request cloning and request parsing (#1914)
Browse files Browse the repository at this point in the history
Co-authored-by: Artem Zakharchenko <kettanaito@gmail.com>
  • Loading branch information
mattcosta7 and kettanaito authored Jan 5, 2024
1 parent 1da80bb commit a79a9d7
Show file tree
Hide file tree
Showing 8 changed files with 287 additions and 144 deletions.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v18.14.2
v18.19.0
37 changes: 31 additions & 6 deletions src/core/handlers/GraphQLHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ export class GraphQLHandler extends RequestHandler<
> {
private endpoint: Path

static parsedRequestCache = new WeakMap<
Request,
ParsedGraphQLRequest<GraphQLVariables>
>()

constructor(
operationType: ExpectedOperationTypeNode,
operationName: GraphQLHandlerNameSelector,
Expand Down Expand Up @@ -133,19 +138,39 @@ export class GraphQLHandler extends RequestHandler<
this.endpoint = endpoint
}

/**
* Parses the request body, once per request, cached across all
* GraphQL handlers. This is done to avoid multiple parsing of the
* request body, which each requires a clone of the request.
*/
async parseGraphQLRequestOrGetFromCache(
request: Request,
): Promise<ParsedGraphQLRequest<GraphQLVariables>> {
if (!GraphQLHandler.parsedRequestCache.has(request)) {
GraphQLHandler.parsedRequestCache.set(
request,
await parseGraphQLRequest(request).catch((error) => {
console.error(error)
return undefined
}),
)
}

return GraphQLHandler.parsedRequestCache.get(request)
}

async parse(args: { request: Request }): Promise<GraphQLRequestParsedResult> {
/**
* If the request doesn't match a specified endpoint, there's no
* need to parse it since there's no case where we would handle this
*/
const match = matchRequestUrl(new URL(args.request.url), this.endpoint)
if (!match.matches) return { match }
if (!match.matches) {
return { match }
}

const parsedResult = await parseGraphQLRequest(args.request).catch(
(error) => {
console.error(error)
return undefined
},
const parsedResult = await this.parseGraphQLRequestOrGetFromCache(
args.request,
)

if (typeof parsedResult === 'undefined') {
Expand Down
34 changes: 30 additions & 4 deletions src/core/handlers/RequestHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ export abstract class RequestHandler<
ResolverExtras extends Record<string, unknown> = any,
HandlerOptions extends RequestHandlerOptions = RequestHandlerOptions,
> {
static cache = new WeakMap<
StrictRequest<DefaultBodyType>,
StrictRequest<DefaultBodyType>
>()

public info: HandlerInfo & RequestHandlerInternalInfo
/**
* Indicates whether this request handler has been used
Expand Down Expand Up @@ -186,6 +191,24 @@ export abstract class RequestHandler<
return {} as ResolverExtras
}

// Clone the request instance before it's passed to the handler phases
// and the response resolver so we can always read it for logging.
// We only clone it once per request to avoid unnecessary overhead.
private cloneRequestOrGetFromCache(
request: StrictRequest<DefaultBodyType>,
): StrictRequest<DefaultBodyType> {
const existingClone = RequestHandler.cache.get(request)

if (typeof existingClone !== 'undefined') {
return existingClone
}

const clonedRequest = request.clone()
RequestHandler.cache.set(request, clonedRequest)

return clonedRequest
}

/**
* Execute this request handler and produce a mocked response
* using the given resolver function.
Expand All @@ -198,9 +221,12 @@ export abstract class RequestHandler<
return null
}

// Clone the request instance before it's passed to the handler phases
// and the response resolver so we can always read it for logging.
const mainRequestRef = args.request.clone()
// Clone the request.
// If this is the first time MSW handles this request, a fresh clone
// will be created and cached. Upon further handling of the same request,
// the request clone from the cache will be reused to prevent abundant
// "abort" listeners and save up resources on cloning.
const requestClone = this.cloneRequestOrGetFromCache(args.request)

const parsedResult = await this.parse({
request: args.request,
Expand Down Expand Up @@ -240,7 +266,7 @@ export abstract class RequestHandler<
const executionResult = this.createExecutionResult({
// Pass the cloned request to the result so that logging
// and other consumers could read its body once more.
request: mainRequestRef,
request: requestClone,
response: mockedResponse,
parsedResult,
})
Expand Down
9 changes: 0 additions & 9 deletions src/node/SetupServerApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,6 @@ export class SetupServerApi
*/
private init(): void {
this.interceptor.on('request', async ({ request, requestId }) => {
this.onRequest(request)

const response = await handleRequest(
request,
requestId,
Expand Down Expand Up @@ -112,11 +110,4 @@ export class SetupServerApi
public close(): void {
this.dispose()
}

protected onRequest(request: Request): void {
// Do nothing here.
// Subclasses may hook into the request being intercepted
// to provide additional functionality (e.g. setting the max
// event listener count on request's "AbortController" in Node.js).
}
}
40 changes: 1 addition & 39 deletions src/node/setupServer.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,9 @@
import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest'
import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest'
import { FetchInterceptor } from '@mswjs/interceptors/fetch'
import { defaultMaxListeners, setMaxListeners } from 'node:events'
import { RequestHandler } from '~/core/handlers/RequestHandler'
import { SetupServerApi as BaseSetupServerApi } from './SetupServerApi'
import { SetupServerApi } from './SetupServerApi'
import { SetupServer } from './glossary'
import { isNodeExceptionLike } from './utils/isNodeExceptionLike'

class SetupServerApi extends BaseSetupServerApi implements SetupServer {
/**
* Bump the maximum number of event listeners on the
* request's "AbortSignal". This prepares the request
* for each request handler cloning it at least once.
* Note that cloning a request automatically appends a
* new "abort" event listener to the parent request's
* "AbortController" so if the parent aborts, all the
* clones are automatically aborted.
*/
protected override onRequest(request: Request): void {
try {
setMaxListeners(
Math.max(defaultMaxListeners, this.currentHandlers.length),
request.signal,
)
} catch (error: unknown) {
/**
* @note Mock environments (JSDOM, ...) are not able to implement an internal
* "kIsNodeEventTarget" Symbol that Node.js uses to identify Node.js `EventTarget`s.
* `setMaxListeners` throws an error for non-Node.js `EventTarget`s.
* At the same time, mock environments are also not able to implement the
* internal "events.maxEventTargetListenersWarned" Symbol, which results in
* "MaxListenersExceededWarning" not being printed by Node.js for those anyway.
* The main reason for using `setMaxListeners` is to suppress these warnings in Node.js,
* which won't be printed anyway if `setMaxListeners` fails.
*/
if (
!(isNodeExceptionLike(error) && error.code === 'ERR_INVALID_ARG_TYPE')
) {
throw error
}
}
}
}

/**
* Sets up a requests interception in Node.js with the given request handlers.
Expand Down
152 changes: 108 additions & 44 deletions test/node/regressions/many-request-handlers-jsdom.test.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,129 @@
/**
* @vitest-environment jsdom
*
* @note In JSDOM, the "AbortSignal" class is polyfilled instead of
* using the Node.js global. Because of that, its instances won't
* pass the instance check of "require('event').setMaxListeners"
* (that's based on the internal Node.js symbol), resulting in
* an exception.
* @see https://github.com/mswjs/msw/pull/1779
*/
import { HttpServer } from '@open-draft/test-server/http'
import { graphql, http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

// Create a large number of request handlers.
const restHandlers = new Array(100).fill(null).map((_, index) => {
return http.post(
`https://example.com/resource/${index}`,
async ({ request }) => {
const text = await request.text()
return HttpResponse.text(text + index.toString())
},
)
})

const graphqlHanlers = new Array(100).fill(null).map((_, index) => {
return graphql.query(`Get${index}`, () => {
return HttpResponse.json({ data: { index } })
const httpServer = new HttpServer((app) => {
app.post('/graphql', (_, res) => {
return res.status(500).send('original-response')
})
app.post('/resource/not-defined', (_, res) => {
return res.status(500).send('original-response')
})
})

const server = setupServer(...restHandlers, ...graphqlHanlers)
const server = setupServer()

const requestCloneSpy = vi.spyOn(Request.prototype, 'clone')
const processErrorSpy = vi.spyOn(process.stderr, 'write')

const NUMBER_OF_REQUEST_HANDLERS = 100

beforeAll(() => {
beforeAll(async () => {
await httpServer.listen()
server.listen()
vi.spyOn(process.stderr, 'write')
})

afterAll(() => {
afterEach(() => {
server.resetHandlers()
vi.clearAllMocks()
})

afterAll(async () => {
server.close()
vi.restoreAllMocks()
await httpServer.close()
})

describe('http handlers', () => {
beforeEach(() => {
server.use(
...new Array(NUMBER_OF_REQUEST_HANDLERS).fill(null).map((_, index) => {
return http.post(
httpServer.http.url(`/resource/${index}`),
async ({ request }) => {
const text = await request.text()
return HttpResponse.text(text + index.toString())
},
)
}),
)
})

it('does not print a memory leak warning for the last handler', async () => {
const httpResponse = await fetch(
`${httpServer.http.url(`/resource/${NUMBER_OF_REQUEST_HANDLERS - 1}`)}`,
{
method: 'POST',
body: 'request-body-',
},
).then((response) => response.text())
// Each clone is a new AbortSignal listener which needs to be registered
expect(requestCloneSpy).toHaveBeenCalledTimes(1)
expect(httpResponse).toBe(`request-body-${NUMBER_OF_REQUEST_HANDLERS - 1}`)
expect(processErrorSpy).not.toHaveBeenCalled()
})

it('does not print a memory leak warning for onUnhandledRequest', async () => {
const httpResponse = await fetch(
`${httpServer.http.url(`/resource/not-defined`)}`,
{
method: 'POST',
body: 'request-body-',
},
)
// Each clone is a new AbortSignal listener which needs to be registered
expect(requestCloneSpy).toHaveBeenCalledTimes(2)
expect(httpResponse.status).toBe(500)
expect(processErrorSpy).not.toHaveBeenCalled()
})
})

it('does not print a memory leak warning when having many request handlers', async () => {
const httpResponse = await fetch('https://example.com/resource/42', {
method: 'POST',
body: 'request-body-',
}).then((response) => response.text())
describe('graphql handlers', () => {
beforeEach(() => {
server.use(
...new Array(NUMBER_OF_REQUEST_HANDLERS).fill(null).map((_, index) => {
return graphql.query(`Get${index}`, () => {
return HttpResponse.json({ data: { index } })
})
}),
)
})

it('does not print a memory leak warning', async () => {
const graphqlResponse = await fetch(httpServer.http.url('/graphql'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `query Get${NUMBER_OF_REQUEST_HANDLERS - 1} { index }`,
}),
}).then((response) => response.json())

const graphqlResponse = await fetch('https://example.com', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `query Get42 { index }`,
}),
}).then((response) => response.json())
expect(requestCloneSpy).toHaveBeenCalledTimes(2)
expect(graphqlResponse).toEqual({
data: { index: NUMBER_OF_REQUEST_HANDLERS - 1 },
})
expect(processErrorSpy).not.toHaveBeenCalled()
})

// Must not print any memory leak warnings.
expect(process.stderr.write).not.toHaveBeenCalled()
it('does not print a memory leak warning for onUnhandledRequest', async () => {
const unhandledResponse = await fetch(httpServer.http.url('/graphql'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `query NotDefinedAtAll { index }`,
}),
})

// Must return the mocked response.
expect(httpResponse).toBe('request-body-42')
expect(graphqlResponse).toEqual({ data: { index: 42 } })
expect(unhandledResponse.status).toEqual(500)
expect(requestCloneSpy).toHaveBeenCalledTimes(3)
// Must not print any memory leak warnings.
expect(processErrorSpy).not.toHaveBeenCalled()
})
})
Loading

0 comments on commit a79a9d7

Please sign in to comment.