diff --git a/src/index.ts b/src/index.ts index d98ce0afe..138853e86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,7 @@ import { SCRIPTS } from './session/injectables'; import { acceptCrossOrigin } from './utils/http'; import getAssetPath from './utils/get-asset-path'; import RequestHookEventProvider from './request-pipeline/request-hooks/events/event-provider'; -import { RequestInfo } from './request-pipeline/request-hooks/events/info'; +import { RequestInfo, ResponseInfo } from './request-pipeline/request-hooks/events/info'; import BaseRequestHookEventFactory from './request-pipeline/request-hooks/events/factory/base'; import BaseRequestPipelineContext from './request-pipeline/context/base'; @@ -60,4 +60,5 @@ export default { INJECTABLE_SCRIPTS: SCRIPTS, acceptCrossOrigin, getAssetPath, + ResponseInfo, }; diff --git a/src/request-pipeline/context/base.ts b/src/request-pipeline/context/base.ts index f56a9e4dc..7ee7498eb 100644 --- a/src/request-pipeline/context/base.ts +++ b/src/request-pipeline/context/base.ts @@ -7,6 +7,10 @@ import RequestEventNames from '../request-hooks/events/names'; import ResponseMock from '../request-hooks/response-mock'; import IncomingMessageLike from '../incoming-message-like'; import getMockResponse from '../request-hooks/response-mock/get-response'; +import { PreparedResponseInfo } from '../request-hooks/events/info'; +import ResponseEvent from '../../session/events/response-event'; +import { OnResponseEventData } from '../../typings/context'; +import ConfigureResponseEventOptions from '../../session/events/configure-response-event-options'; export default abstract class BaseRequestPipelineContext { @@ -14,6 +18,7 @@ export default abstract class BaseRequestPipelineContext { public requestId: string; public reqOpts: RequestOptions; public mock: ResponseMock; + public onResponseEventData: OnResponseEventData[] = []; protected constructor (requestId: string) { this.requestFilterRules = []; @@ -35,6 +40,10 @@ export default abstract class BaseRequestPipelineContext { this.reqOpts = eventFactory.createRequestOptions(); } + public getOnResponseEventData ({ includeBody }: { includeBody: boolean }): OnResponseEventData[] { + return this.onResponseEventData.filter(eventData => eventData.opts.includeBody === includeBody); + } + public async onRequestHookRequest (eventProvider: RequestHookEventProvider, eventFactory: BaseRequestHookEventFactory): Promise { const requestInfo = eventFactory.createRequestInfo(); @@ -54,6 +63,29 @@ export default abstract class BaseRequestPipelineContext { }); } + public async onRequestHookConfigureResponse (eventProvider: RequestHookEventProvider, eventFactory: BaseRequestHookEventFactory): Promise { + await Promise.all(this.requestFilterRules.map(async rule => { + const configureResponseEvent = eventFactory.createConfigureResponseEvent(rule); + + await eventProvider.callRequestEventCallback(RequestEventNames.onConfigureResponse, rule, configureResponseEvent); + + this.onResponseEventData.push({ + rule: configureResponseEvent.requestFilterRule, + opts: configureResponseEvent.opts, + }); + })); + } + + public async onRequestHookResponse (eventProvider: RequestHookEventProvider, eventFactory: BaseRequestHookEventFactory, rule: RequestFilterRule, opts: ConfigureResponseEventOptions): Promise { + const responseInfo = eventFactory.createResponseInfo(); + const preparedResponseInfo = new PreparedResponseInfo(responseInfo, opts); + const responseEvent = new ResponseEvent(rule, preparedResponseInfo); + + await eventProvider.callRequestEventCallback(RequestEventNames.onResponse, rule, responseEvent); + + return responseEvent; + } + public async getMockResponse (): Promise { this.mock.setRequestOptions(this.reqOpts); diff --git a/src/request-pipeline/context/index.ts b/src/request-pipeline/context/index.ts index 69ac24087..01df05adc 100644 --- a/src/request-pipeline/context/index.ts +++ b/src/request-pipeline/context/index.ts @@ -32,6 +32,9 @@ import { Http2Response } from '../destination-request/http2'; import BaseRequestPipelineContext from './base'; import RequestPipelineRequestHookEventFactory from '../request-hooks/events/factory'; import RequestHookEventProvider from '../request-hooks/events/event-provider'; +import { PassThrough } from 'stream'; +import promisifyStream from '../../utils/promisify-stream'; +import { toReadableStream } from '../../utils/buffer'; export interface DestInfo { url: string; @@ -110,7 +113,6 @@ export default class RequestPipelineContext extends BaseRequestPipelineContext { isWebSocketConnectionReset = false; contentInfo: ContentInfo; restoringStorages: StoragesSnapshot; - onResponseEventData: OnResponseEventData[] = []; parsedClientSyncCookie: ParsedClientSyncCookie; isFileProtocol: boolean; nonProcessedDestResBody: Buffer; @@ -511,10 +513,6 @@ export default class RequestPipelineContext extends BaseRequestPipelineContext { logger.proxy.onMockResponseError(this.requestFilterRules[0], this.mock.error as Error); } - getOnResponseEventData ({ includeBody }: { includeBody: boolean }): OnResponseEventData[] { - return this.onResponseEventData.filter(eventData => eventData.opts.includeBody === includeBody); - } - resolveInjectableUrl (url: string): string { return this.serverInfo.domain + url; } @@ -555,4 +553,36 @@ export default class RequestPipelineContext extends BaseRequestPipelineContext { if (requestCache.shouldCache(this) && !IncomingMessageLike.isIncomingMessageLike(res)) this.temporaryCacheEntry = requestCache.create(this.reqOpts, res); } + + public async callOnResponseEventCallbackWithoutBodyForNonProcessedResource (ctx: RequestPipelineContext, onResponseEventDataWithoutBody: OnResponseEventData[]) { + await Promise.all(onResponseEventDataWithoutBody.map(async eventData => { + await ctx.onRequestHookResponse(ctx.session.requestHookEventProvider, ctx.eventFactory, eventData.rule, eventData.opts); + })); + + ctx.destRes.pipe(ctx.res); + } + + public async callOnResponseEventCallbackForMotModifiedResource (ctx: RequestPipelineContext) { + await Promise.all(ctx.onResponseEventData.map(async eventData => { + await ctx.onRequestHookResponse(ctx.session.requestHookEventProvider, ctx.eventFactory, eventData.rule, eventData.opts); + })); + + ctx.res.end(); + } + + public async callOnResponseEventCallbackWithBodyForNonProcessedRequest (ctx: RequestPipelineContext, onResponseEventDataWithBody: OnResponseEventData[]) { + const destResBodyCollectorStream = new PassThrough(); + + ctx.destRes.pipe(destResBodyCollectorStream); + + promisifyStream(destResBodyCollectorStream).then(async data => { + ctx.saveNonProcessedDestResBody(data); + + await Promise.all(onResponseEventDataWithBody.map(async eventData => { + await ctx.onRequestHookResponse(ctx.session.requestHookEventProvider, ctx.eventFactory, eventData.rule, eventData.opts); + })); + + toReadableStream(data).pipe(ctx.res); + }); + } } diff --git a/src/request-pipeline/request-hooks/events/factory/base.ts b/src/request-pipeline/request-hooks/events/factory/base.ts index edfc1dc99..37b1e6693 100644 --- a/src/request-pipeline/request-hooks/events/factory/base.ts +++ b/src/request-pipeline/request-hooks/events/factory/base.ts @@ -1,8 +1,12 @@ -import { RequestInfo } from '../info'; +import { RequestInfo, ResponseInfo } from '../info'; import RequestOptions from '../../../request-options'; +import ConfigureResponseEvent from '../../../../session/events/configure-response-event'; +import RequestFilterRule from '../../request-filter-rule'; export default abstract class BaseRequestHookEventFactory { public abstract createRequestInfo (): RequestInfo; public abstract createRequestOptions (): RequestOptions; + public abstract createConfigureResponseEvent (rule: RequestFilterRule): ConfigureResponseEvent; + public abstract createResponseInfo (): ResponseInfo; } diff --git a/src/request-pipeline/request-hooks/events/factory/index.ts b/src/request-pipeline/request-hooks/events/factory/index.ts index 0efee3d2e..ba80e6537 100644 --- a/src/request-pipeline/request-hooks/events/factory/index.ts +++ b/src/request-pipeline/request-hooks/events/factory/index.ts @@ -1,7 +1,9 @@ import BaseRequestHookEventFactory from './base'; -import { RequestInfo } from '../info'; +import { RequestInfo, ResponseInfo } from '../info'; import RequestPipelineContext from '../../../context'; import RequestOptions from '../../../request-options'; +import ConfigureResponseEvent from '../../../../session/events/configure-response-event'; +import RequestFilterRule from '../../request-filter-rule'; export default class RequestPipelineRequestHookEventFactory extends BaseRequestHookEventFactory { @@ -12,6 +14,7 @@ export default class RequestPipelineRequestHookEventFactory extends BaseRequestH this._ctx = ctx; } + public createRequestInfo (): RequestInfo { return RequestInfo.from(this._ctx); } @@ -19,4 +22,12 @@ export default class RequestPipelineRequestHookEventFactory extends BaseRequestH public createRequestOptions (): RequestOptions { return RequestOptions.createFrom(this._ctx); } + + public createConfigureResponseEvent (rule: RequestFilterRule): ConfigureResponseEvent { + return new ConfigureResponseEvent(rule, this._ctx); + } + + public createResponseInfo (): ResponseInfo { + return ResponseInfo.from(this._ctx); + } } diff --git a/src/request-pipeline/stages.ts b/src/request-pipeline/stages.ts index 98ef62562..5d66afbb9 100644 --- a/src/request-pipeline/stages.ts +++ b/src/request-pipeline/stages.ts @@ -1,23 +1,12 @@ import RequestPipelineContext from './context'; import logger from '../utils/logger'; import { fetchBody } from '../utils/http'; -import { - callOnConfigureResponseEventForNonProcessedRequest, - callOnResponseEventCallbackForFailedSameOriginCheck, - callOnResponseEventCallbackForMotModifiedResource, - callOnResponseEventCallbackWithBodyForNonProcessedRequest, - callOnResponseEventCallbackWithoutBodyForNonProcessedResource, - callResponseEventCallbackForProcessedRequest, - error, - sendRequest, -} from './utils'; -import ConfigureResponseEvent from '../session/events/configure-response-event'; -import ConfigureResponseEventOptions from '../session/events/configure-response-event-options'; -import RequestEventNames from './request-hooks/events/names'; +import { error, sendRequest } from './utils'; import { respondOnWebSocket } from './websocket'; import { noop } from 'lodash'; import { process as processResource } from '../processing/resources'; import { connectionResetGuard } from './connection-reset-guard'; +import ConfigureResponseEventOptions from '../session/events/configure-response-event-options'; const EVENT_SOURCE_REQUEST_TIMEOUT = 60 * 60 * 1000; @@ -72,12 +61,11 @@ export default [ ctx.isSameOriginPolicyFailed = true; - await ctx.forEachRequestFilterRule(async rule => { - const configureResponseEvent = new ConfigureResponseEvent(rule, ctx, ConfigureResponseEventOptions.DEFAULT); + await ctx.onRequestHookConfigureResponse(ctx.session.requestHookEventProvider, ctx.eventFactory); - await ctx.session.requestHookEventProvider.callRequestEventCallback(RequestEventNames.onConfigureResponse, rule, configureResponseEvent); - await callOnResponseEventCallbackForFailedSameOriginCheck(ctx, rule, ConfigureResponseEventOptions.DEFAULT); - }); + await Promise.all(ctx.onResponseEventData.map(async eventData => { + await ctx.onRequestHookResponse(ctx.session.requestHookEventProvider, ctx.eventFactory, eventData.rule, ConfigureResponseEventOptions.DEFAULT); + })); logger.proxy.onCORSFailed(ctx); }, @@ -102,19 +90,20 @@ export default [ // NOTE: Just pipe the content body to the browser if we don't need to process it. else { - await callOnConfigureResponseEventForNonProcessedRequest(ctx); + await ctx.onRequestHookConfigureResponse(ctx.session.requestHookEventProvider, ctx.eventFactory); + ctx.sendResponseHeaders(); if (ctx.contentInfo.isNotModified) - return await callOnResponseEventCallbackForMotModifiedResource(ctx); + return await ctx.callOnResponseEventCallbackForMotModifiedResource(ctx); const onResponseEventDataWithBody = ctx.getOnResponseEventData({ includeBody: true }); const onResponseEventDataWithoutBody = ctx.getOnResponseEventData({ includeBody: false }); if (onResponseEventDataWithBody.length) - await callOnResponseEventCallbackWithBodyForNonProcessedRequest(ctx, onResponseEventDataWithBody); + await ctx.callOnResponseEventCallbackWithBodyForNonProcessedRequest(ctx, onResponseEventDataWithBody); else if (onResponseEventDataWithoutBody.length) - await callOnResponseEventCallbackWithoutBodyForNonProcessedResource(ctx, onResponseEventDataWithoutBody); + await ctx.callOnResponseEventCallbackWithoutBodyForNonProcessedResource(ctx, onResponseEventDataWithoutBody); else if (ctx.req.socket.destroyed && !ctx.isDestResReadableEnded) ctx.destRes.destroy(); else { @@ -148,19 +137,13 @@ export default [ }, async function sendProxyResponse (ctx: RequestPipelineContext) { - const configureResponseEvents = await Promise.all(ctx.requestFilterRules.map(async rule => { - const configureResponseEvent = new ConfigureResponseEvent(rule, ctx, ConfigureResponseEventOptions.DEFAULT); - - await ctx.session.requestHookEventProvider.callRequestEventCallback(RequestEventNames.onConfigureResponse, rule, configureResponseEvent); - - return configureResponseEvent; - })); + await ctx.onRequestHookConfigureResponse(ctx.session.requestHookEventProvider, ctx.eventFactory); ctx.sendResponseHeaders(); connectionResetGuard(async () => { - await Promise.all(configureResponseEvents.map(async configureResponseEvent => { - await callResponseEventCallbackForProcessedRequest(ctx, configureResponseEvent); + await Promise.all(ctx.onResponseEventData.map(async eventData => { + await ctx.onRequestHookResponse(ctx.session.requestHookEventProvider, ctx.eventFactory, eventData.rule, eventData.opts); })); ctx.res.write(ctx.destResBody); diff --git a/src/request-pipeline/utils.ts b/src/request-pipeline/utils.ts index 456d3e538..0e2f0f353 100644 --- a/src/request-pipeline/utils.ts +++ b/src/request-pipeline/utils.ts @@ -1,21 +1,6 @@ import RequestPipelineContext, { DestinationResponse } from './context'; -import RequestFilterRule from './request-hooks/request-filter-rule'; - -import { - ResponseInfo, - PreparedResponseInfo, -} from './request-hooks/events/info'; - -import { OnResponseEventData } from '../typings/context'; import FileRequest from './file-request'; import DestinationRequest from './destination-request'; -import promisifyStream from '../utils/promisify-stream'; -import ConfigureResponseEvent from '../session/events/configure-response-event'; -import ResponseEvent from '../session/events/response-event'; -import RequestEventNames from './request-hooks/events/names'; -import ConfigureResponseEventOptions from '../session/events/configure-response-event-options'; -import { toReadableStream } from '../utils/buffer'; -import { PassThrough } from 'stream'; import { getText, MESSAGE } from '../messages'; import logger from '../utils/logger'; import { getFormattedInvalidCharacters } from './http-header-parser'; @@ -110,80 +95,3 @@ export function error (ctx: RequestPipelineContext, err: string) { ctx.closeWithError(500, err.toString()); } -export async function callResponseEventCallbackForProcessedRequest (ctx: RequestPipelineContext, configureResponseEvent: ConfigureResponseEvent) { - const responseInfo = ResponseInfo.from(ctx); - const preparedResponseInfo = new PreparedResponseInfo(responseInfo, configureResponseEvent.opts); - const responseEvent = new ResponseEvent(configureResponseEvent.requestFilterRule, preparedResponseInfo); - - await ctx.session.requestHookEventProvider.callRequestEventCallback(RequestEventNames.onResponse, configureResponseEvent.requestFilterRule, responseEvent); - - return responseEvent; -} - -export async function callOnResponseEventCallbackForFailedSameOriginCheck (ctx: RequestPipelineContext, rule: RequestFilterRule, configureOpts: ConfigureResponseEventOptions) { - const responseInfo = ResponseInfo.from(ctx); - const preparedResponseInfo = new PreparedResponseInfo(responseInfo, configureOpts); - const responseEvent = new ResponseEvent(rule, preparedResponseInfo); - - await ctx.session.requestHookEventProvider.callRequestEventCallback(RequestEventNames.onResponse, rule, responseEvent); - - return responseEvent; -} - -export async function callOnConfigureResponseEventForNonProcessedRequest (ctx: RequestPipelineContext) { - await ctx.forEachRequestFilterRule(async rule => { - const configureResponseEvent = new ConfigureResponseEvent(rule, ctx, ConfigureResponseEventOptions.DEFAULT); - - await ctx.session.requestHookEventProvider.callRequestEventCallback(RequestEventNames.onConfigureResponse, rule, configureResponseEvent); - - ctx.onResponseEventData.push({ rule, opts: configureResponseEvent.opts }); - }); -} - -export async function callOnResponseEventCallbackWithBodyForNonProcessedRequest (ctx: RequestPipelineContext, onResponseEventDataWithBody: OnResponseEventData[]) { - const destResBodyCollectorStream = new PassThrough(); - - ctx.destRes.pipe(destResBodyCollectorStream); - - promisifyStream(destResBodyCollectorStream).then(async data => { - ctx.saveNonProcessedDestResBody(data); - - const responseInfo = ResponseInfo.from(ctx); - - await Promise.all(onResponseEventDataWithBody.map(async ({ rule, opts }) => { - const preparedResponseInfo = new PreparedResponseInfo(responseInfo, opts); - const responseEvent = new ResponseEvent(rule, preparedResponseInfo); - - await ctx.session.requestHookEventProvider.callRequestEventCallback(RequestEventNames.onResponse, rule, responseEvent); - })); - - toReadableStream(data).pipe(ctx.res); - }); -} - -export async function callOnResponseEventCallbackWithoutBodyForNonProcessedResource (ctx: RequestPipelineContext, onResponseEventDataWithoutBody: OnResponseEventData[]) { - const responseInfo = ResponseInfo.from(ctx); - - await Promise.all(onResponseEventDataWithoutBody.map(async item => { - const preparedResponseInfo = new PreparedResponseInfo(responseInfo, item.opts); - const responseEvent = new ResponseEvent(item.rule, preparedResponseInfo); - - await ctx.session.requestHookEventProvider.callRequestEventCallback(RequestEventNames.onResponse, item.rule, responseEvent); - })); - - ctx.destRes.pipe(ctx.res); -} - -export async function callOnResponseEventCallbackForMotModifiedResource (ctx: RequestPipelineContext) { - const responseInfo = ResponseInfo.from(ctx); - - await Promise.all(ctx.onResponseEventData.map(async item => { - const preparedResponseInfo = new PreparedResponseInfo(responseInfo, item.opts); - const responseEvent = new ResponseEvent(item.rule, preparedResponseInfo); - - await ctx.session.requestHookEventProvider.callRequestEventCallback(RequestEventNames.onResponse, item.rule, responseEvent); - })); - - ctx.res.end(); -} - diff --git a/src/session/events/configure-response-event.ts b/src/session/events/configure-response-event.ts index 66e37834c..0a0bb033f 100644 --- a/src/session/events/configure-response-event.ts +++ b/src/session/events/configure-response-event.ts @@ -16,7 +16,7 @@ export default class ConfigureResponseEvent { public opts: ConfigureResponseEventOptions; public id: string; - constructor (requestFilterRule: RequestFilterRule, requestContext: RequestPipelineContext | null, opts: ConfigureResponseEventOptions) { + constructor (requestFilterRule: RequestFilterRule, requestContext: RequestPipelineContext | null, opts = ConfigureResponseEventOptions.DEFAULT) { this.requestFilterRule = requestFilterRule; this._requestContext = requestContext; this.opts = opts; diff --git a/ts-defs/index.d.ts b/ts-defs/index.d.ts index 12deae59f..c4497fdce 100644 --- a/ts-defs/index.d.ts +++ b/ts-defs/index.d.ts @@ -212,6 +212,10 @@ declare module 'testcafe-hammerhead' { proxyless: boolean; } + export interface OnResponseEventData { + rule: RequestFilterRule; + opts: ConfigureResponseEventOptions; + } /** Base class for emitting request hook events **/ export class RequestHookEventProvider { @@ -375,6 +379,9 @@ declare module 'testcafe-hammerhead' { /** RequestFilterRule associated with event **/ requestFilterRule: RequestFilterRule; + /** Creates an instance of ConfigureResponseEvent **/ + constructor (requestFilterRule: RequestFilterRule, requestContext: any, opts?: ConfigureResponseEventOptions); + /** Set header of the result response **/ setHeader(name: string, value: string): Promise; @@ -432,6 +439,18 @@ declare module 'testcafe-hammerhead' { static from (data: unknown): RequestEvent; } + /** The ResponseInfo class is necessary for construction ResponseEvent class **/ + export class ResponseInfo { + requestId: string; + statusCode: number; + sessionId: string; + headers: OutgoingHttpHeaders; + body: Buffer; + isSameOriginPolicyFailed: boolean; + + constructor (init: ResponseInfo); + } + /** The ResponseEvent describes the response part of the query captured with request hook **/ export class ResponseEvent { /** The unique identifier of the event **/ @@ -584,6 +603,12 @@ declare module 'testcafe-hammerhead' { /** Creates a new RequestEvent instance **/ public abstract createRequestOptions (): RequestOptions; + + /** Creates a new ConfigureResponseEvent instance **/ + public abstract createConfigureResponseEvent (rule: RequestFilterRule): ConfigureResponseEvent; + + /** Create a new ResponseInfo instance **/ + public abstract createResponseInfo (): ResponseInfo; } /** Base class for building request pipeline contexts **/ @@ -602,16 +627,28 @@ declare module 'testcafe-hammerhead' { /** Request identifier **/ requestId: string; + /** Information for generating the response events **/ + onResponseEventData: OnResponseEventData[]; + /** Set request options for the current context **/ setRequestOptions (eventFactory: BaseRequestHookEventFactory): void; /** Raise onRequest event **/ onRequestHookRequest (eventProvider: RequestHookEventProvider, eventFactory: BaseRequestHookEventFactory): Promise; + /** Raise onConfigureResponse event **/ + onRequestHookConfigureResponse (eventProvider: RequestHookEventProvider, eventFactory: BaseRequestHookEventFactory): Promise; + + /** Raise onResponse event **/ + onRequestHookResponse (eventProvider: RequestHookEventProvider, eventFactory: BaseRequestHookEventFactory, rule: RequestFilterRule, opts: ConfigureResponseEventOptions): Promise; + /** Get mock response **/ getMockResponse (): Promise; /** Handle mock error **/ handleMockError (eventProvider: RequestHookEventProvider): Promise; + + /** Get OnResponseEventData depending on specified filter **/ + getOnResponseEventData ({ includeBody }: { includeBody: boolean }): OnResponseEventData[]; } }