Path: blob/main/puppet-chrome/lib/NetworkManager.ts
1028 views
import { Protocol } from 'devtools-protocol';1import { getResourceTypeForChromeValue } from '@secret-agent/interfaces/ResourceType';2import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';3import {4IPuppetNetworkEvents,5IPuppetResourceRequest,6} from '@secret-agent/interfaces/IPuppetNetworkEvents';7import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';8import EventSubscriber from '@secret-agent/commons/EventSubscriber';9import { IBoundLog } from '@secret-agent/interfaces/ILog';10import { URL } from 'url';11import IProxyConnectionOptions from '@secret-agent/interfaces/IProxyConnectionOptions';12import { DevtoolsSession } from './DevtoolsSession';13import AuthChallengeResponse = Protocol.Fetch.AuthChallengeResponseResponse;14import Fetch = Protocol.Fetch;15import RequestWillBeSentEvent = Protocol.Network.RequestWillBeSentEvent;16import WebSocketFrameSentEvent = Protocol.Network.WebSocketFrameSentEvent;17import WebSocketFrameReceivedEvent = Protocol.Network.WebSocketFrameReceivedEvent;18import WebSocketWillSendHandshakeRequestEvent = Protocol.Network.WebSocketWillSendHandshakeRequestEvent;19import ResponseReceivedEvent = Protocol.Network.ResponseReceivedEvent;20import RequestPausedEvent = Protocol.Fetch.RequestPausedEvent;21import LoadingFinishedEvent = Protocol.Network.LoadingFinishedEvent;22import LoadingFailedEvent = Protocol.Network.LoadingFailedEvent;23import RequestServedFromCacheEvent = Protocol.Network.RequestServedFromCacheEvent;24import RequestWillBeSentExtraInfoEvent = Protocol.Network.RequestWillBeSentExtraInfoEvent;2526interface IResourcePublishing {27hasRequestWillBeSentEvent: boolean;28emitTimeout?: NodeJS.Timeout;29isPublished?: boolean;30isDetailsEmitted?: boolean;31}3233const mbBytes = 1028 * 1028;3435export class NetworkManager extends TypedEventEmitter<IPuppetNetworkEvents> {36protected readonly logger: IBoundLog;37private readonly devtools: DevtoolsSession;38private readonly attemptedAuthentications = new Set<string>();39private readonly redirectsById = new Map<string, IPuppetResourceRequest[]>();40private readonly requestsById = new Map<string, IPuppetResourceRequest>();41private readonly requestPublishingById = new Map<string, IResourcePublishing>();4243private readonly navigationRequestIdsToLoaderId = new Map<string, string>();4445private parentManager?: NetworkManager;46private readonly eventSubscriber = new EventSubscriber();47private mockNetworkRequests?: (48request: Protocol.Fetch.RequestPausedEvent,49) => Promise<Protocol.Fetch.FulfillRequestRequest>;5051private readonly proxyConnectionOptions: IProxyConnectionOptions;52private isChromeRetainingResources = false;5354constructor(55devtoolsSession: DevtoolsSession,56logger: IBoundLog,57proxyConnectionOptions?: IProxyConnectionOptions,58) {59super();60this.devtools = devtoolsSession;61this.logger = logger.createChild(module);62this.proxyConnectionOptions = proxyConnectionOptions;63const events = this.eventSubscriber;64const devtools = this.devtools;65events.on(devtools, 'Fetch.requestPaused', this.onRequestPaused.bind(this));66events.on(devtools, 'Fetch.authRequired', this.onAuthRequired.bind(this));67events.on(68devtools,69'Network.webSocketWillSendHandshakeRequest',70this.onWebsocketHandshake.bind(this),71);72events.on(devtools, 'Network.webSocketFrameReceived', this.onWebsocketFrame.bind(this, true));73events.on(devtools, 'Network.webSocketFrameSent', this.onWebsocketFrame.bind(this, false));74events.on(devtools, 'Network.requestWillBeSent', this.onNetworkRequestWillBeSent.bind(this));75events.on(76devtools,77'Network.requestWillBeSentExtraInfo',78this.onNetworkRequestWillBeSentExtraInfo.bind(this),79);80events.on(devtools, 'Network.responseReceived', this.onNetworkResponseReceived.bind(this));81events.on(devtools, 'Network.loadingFinished', this.onLoadingFinished.bind(this));82events.on(devtools, 'Network.loadingFailed', this.onLoadingFailed.bind(this));83events.on(84devtools,85'Network.requestServedFromCache',86this.onNetworkRequestServedFromCache.bind(this),87);88}8990public emit<91K extends (keyof IPuppetNetworkEvents & string) | (keyof IPuppetNetworkEvents & symbol)92>(eventType: K, event?: IPuppetNetworkEvents[K]): boolean {93if (this.parentManager) {94this.parentManager.emit(eventType, event);95}96return super.emit(eventType, event);97}9899public async initialize(): Promise<void> {100if (this.mockNetworkRequests) {101return this.devtools102.send('Fetch.enable', {103handleAuthRequests: !!this.proxyConnectionOptions?.password,104})105.catch(err => err);106}107108const maxResourceBufferSize = this.proxyConnectionOptions?.address ? mbBytes : 5 * mbBytes; // 5mb max109if (maxResourceBufferSize > 0) this.isChromeRetainingResources = true;110111const errors = await Promise.all([112this.devtools113.send('Network.enable', {114maxPostDataSize: 0,115maxResourceBufferSize,116maxTotalBufferSize: maxResourceBufferSize * 5,117})118.catch(err => err),119this.proxyConnectionOptions?.password120? this.devtools121.send('Fetch.enable', {122handleAuthRequests: true,123})124.catch(err => err)125: Promise.resolve(),126]);127for (const error of errors) {128if (error && error instanceof Error) throw error;129}130}131132public async setNetworkInterceptor(133mockNetworkRequests: NetworkManager['mockNetworkRequests'],134disableSessionLogging: boolean,135): Promise<void> {136this.mockNetworkRequests = mockNetworkRequests;137const promises: Promise<any>[] = [];138if (disableSessionLogging) {139promises.push(this.devtools.send('Network.disable'));140}141promises.push(142this.devtools.send('Fetch.enable', {143handleAuthRequests: !!this.proxyConnectionOptions?.password,144}),145);146await Promise.all(promises);147}148149public close(): void {150this.eventSubscriber.close();151this.cancelPendingEvents('NetworkManager closed');152}153154public initializeFromParent(parentManager: NetworkManager): Promise<void> {155this.parentManager = parentManager;156return this.initialize();157}158159private onAuthRequired(event: Protocol.Fetch.AuthRequiredEvent): void {160const authChallengeResponse = {161response: AuthChallengeResponse.Default,162} as Fetch.AuthChallengeResponse;163164if (this.attemptedAuthentications.has(event.requestId)) {165authChallengeResponse.response = AuthChallengeResponse.CancelAuth;166} else if (this.proxyConnectionOptions?.password) {167this.attemptedAuthentications.add(event.requestId);168169authChallengeResponse.response = AuthChallengeResponse.ProvideCredentials;170authChallengeResponse.username = 'puppet-chrome';171authChallengeResponse.password = this.proxyConnectionOptions.password;172}173this.devtools174.send('Fetch.continueWithAuth', {175requestId: event.requestId,176authChallengeResponse,177})178.catch(error => {179if (error instanceof CanceledPromiseError) return;180this.logger.info('NetworkManager.continueWithAuthError', {181error,182requestId: event.requestId,183url: event.request.url,184});185});186}187188private async onRequestPaused(networkRequest: RequestPausedEvent): Promise<void> {189try {190if (this.mockNetworkRequests) {191const response = await this.mockNetworkRequests(networkRequest);192if (response) {193return await this.devtools.send('Fetch.fulfillRequest', response);194}195}196197await this.devtools.send('Fetch.continueRequest', {198requestId: networkRequest.requestId,199});200} catch (error) {201if (error instanceof CanceledPromiseError) return;202this.logger.info('NetworkManager.continueRequestError', {203error,204requestId: networkRequest.requestId,205url: networkRequest.request.url,206});207}208209let resource: IPuppetResourceRequest;210try {211// networkId corresponds to onNetworkRequestWillBeSent212resource = <IPuppetResourceRequest>{213browserRequestId: networkRequest.networkId ?? networkRequest.requestId,214resourceType: getResourceTypeForChromeValue(215networkRequest.resourceType,216networkRequest.request.method,217),218url: new URL(networkRequest.request.url),219method: networkRequest.request.method,220isSSL: networkRequest.request.url.startsWith('https'),221isFromRedirect: false,222isUpgrade: false,223isHttp2Push: false,224isServerHttp2: false,225requestTime: new Date(),226protocol: null,227hasUserGesture: false,228documentUrl: networkRequest.request.headers.Referer,229frameId: networkRequest.frameId,230};231} catch (error) {232this.logger.warn('NetworkManager.onRequestPausedError', {233error,234url: networkRequest.request.url,235browserRequestId: networkRequest.requestId,236});237return;238}239const existing = this.requestsById.get(resource.browserRequestId);240241if (existing) {242if (existing.url === resource.url) {243resource.requestHeaders = existing.requestHeaders ?? {};244}245246if (existing.resourceType) resource.resourceType = existing.resourceType;247resource.redirectedFromUrl = existing.redirectedFromUrl;248}249this.mergeRequestHeaders(resource, networkRequest.request.headers);250251if (networkRequest.networkId && !this.requestsById.has(networkRequest.networkId)) {252this.requestsById.set(networkRequest.networkId, resource);253}254if (networkRequest.requestId !== networkRequest.networkId) {255this.requestsById.set(networkRequest.requestId, resource);256}257258// requests from service workers (and others?) will never register with RequestWillBeSentEvent259// -- they don't have networkIds260this.emitResourceRequested(resource.browserRequestId);261}262263private onNetworkRequestWillBeSent(networkRequest: RequestWillBeSentEvent): void {264const redirectedFromUrl = networkRequest.redirectResponse?.url;265266const isNavigation =267networkRequest.requestId === networkRequest.loaderId && networkRequest.type === 'Document';268if (isNavigation) {269this.navigationRequestIdsToLoaderId.set(networkRequest.requestId, networkRequest.loaderId);270}271let resource: IPuppetResourceRequest;272try {273resource = <IPuppetResourceRequest>{274url: new URL(networkRequest.request.url),275isSSL: networkRequest.request.url.startsWith('https'),276isFromRedirect: !!redirectedFromUrl,277isUpgrade: false,278isHttp2Push: false,279isServerHttp2: false,280requestTime: new Date(networkRequest.wallTime * 1e3),281protocol: null,282browserRequestId: networkRequest.requestId,283resourceType: getResourceTypeForChromeValue(284networkRequest.type,285networkRequest.request.method,286),287method: networkRequest.request.method,288hasUserGesture: networkRequest.hasUserGesture,289documentUrl: networkRequest.documentURL,290redirectedFromUrl,291frameId: networkRequest.frameId,292};293} catch (error) {294this.logger.warn('NetworkManager.onNetworkRequestWillBeSentError', {295error,296url: networkRequest.request.url,297browserRequestId: networkRequest.requestId,298});299return;300}301302const publishing = this.getPublishingForRequestId(resource.browserRequestId, true);303publishing.hasRequestWillBeSentEvent = true;304305const existing = this.requestsById.get(resource.browserRequestId);306307const isNewRedirect = redirectedFromUrl && existing && existing.url !== resource.url;308309// NOTE: same requestId will be used in devtools for redirected resources310if (existing) {311if (isNewRedirect) {312const existingRedirects = this.redirectsById.get(resource.browserRequestId) ?? [];313existing.redirectedToUrl = networkRequest.request.url;314existing.responseHeaders = networkRequest.redirectResponse.headers;315existing.status = networkRequest.redirectResponse.status;316existing.statusMessage = networkRequest.redirectResponse.statusText;317this.redirectsById.set(resource.browserRequestId, [...existingRedirects, existing]);318publishing.isPublished = false;319clearTimeout(publishing.emitTimeout);320publishing.emitTimeout = undefined;321} else {322// preserve headers and frameId from a fetch or networkWillRequestExtraInfo323resource.requestHeaders = existing.requestHeaders ?? {};324}325}326327this.requestsById.set(resource.browserRequestId, resource);328this.mergeRequestHeaders(resource, networkRequest.request.headers);329330this.emitResourceRequested(resource.browserRequestId);331}332333private onNetworkRequestWillBeSentExtraInfo(334networkRequest: RequestWillBeSentExtraInfoEvent,335): void {336const requestId = networkRequest.requestId;337let resource = this.requestsById.get(requestId);338if (!resource) {339resource = {} as any;340this.requestsById.set(requestId, resource);341}342343this.mergeRequestHeaders(resource, networkRequest.headers);344345const hasNetworkRequest =346this.requestPublishingById.get(requestId)?.hasRequestWillBeSentEvent === true;347if (hasNetworkRequest) {348this.doEmitResourceRequested(resource.browserRequestId);349}350}351352private mergeRequestHeaders(353resource: IPuppetResourceRequest,354requestHeaders: RequestWillBeSentEvent['request']['headers'],355): void {356resource.requestHeaders ??= {};357for (const [key, value] of Object.entries(requestHeaders)) {358const titleKey = `${key359.split('-')360.map(x => x[0].toUpperCase() + x.slice(1))361.join('-')}`;362if (resource.requestHeaders[titleKey] && titleKey !== key) {363delete resource.requestHeaders[titleKey];364}365resource.requestHeaders[key] = value;366}367}368369private emitResourceRequested(browserRequestId: string): void {370const resource = this.requestsById.get(browserRequestId);371if (!resource) return;372373const publishing = this.getPublishingForRequestId(browserRequestId, true);374// if we're already waiting, go ahead and publish now375if (publishing.emitTimeout && !publishing.isPublished) {376this.doEmitResourceRequested(browserRequestId);377return;378}379380// give it a small period to add extra info. no network id means it's running outside the normal "requestWillBeSent" flow381publishing.emitTimeout = setTimeout(382this.doEmitResourceRequested.bind(this),383200,384browserRequestId,385).unref();386}387388private doEmitResourceRequested(browserRequestId: string): boolean {389const resource = this.requestsById.get(browserRequestId);390if (!resource) return false;391if (!resource.url) return false;392393const publishing = this.getPublishingForRequestId(browserRequestId, true);394clearTimeout(publishing.emitTimeout);395publishing.emitTimeout = undefined;396397const event = <IPuppetNetworkEvents['resource-will-be-requested']>{398resource,399isDocumentNavigation: this.navigationRequestIdsToLoaderId.has(browserRequestId),400frameId: resource.frameId,401redirectedFromUrl: resource.redirectedFromUrl,402loaderId: this.navigationRequestIdsToLoaderId.get(browserRequestId),403};404405// NOTE: same requestId will be used in devtools for redirected resources406if (!publishing.isPublished) {407publishing.isPublished = true;408this.emit('resource-will-be-requested', event);409} else if (!publishing.isDetailsEmitted) {410publishing.isDetailsEmitted = true;411this.emit('resource-was-requested', event);412}413}414415private onNetworkResponseReceived(event: ResponseReceivedEvent): void {416const { response, requestId, loaderId, frameId, type } = event;417418const resource = this.requestsById.get(requestId);419if (resource) {420resource.responseHeaders = response.headers;421resource.status = response.status;422resource.statusMessage = response.statusText;423resource.remoteAddress = `${response.remoteIPAddress}:${response.remotePort}`;424resource.protocol = response.protocol;425resource.responseUrl = response.url;426resource.responseTime = new Date();427if (response.fromDiskCache) resource.browserServedFromCache = 'disk';428if (response.fromServiceWorker) resource.browserServedFromCache = 'service-worker';429if (response.fromPrefetchCache) resource.browserServedFromCache = 'prefetch';430431if (response.requestHeaders) this.mergeRequestHeaders(resource, response.requestHeaders);432if (!resource.url) {433resource.url = new URL(response.url);434resource.frameId = frameId;435resource.browserRequestId = requestId;436}437if (!this.requestPublishingById.get(requestId)?.isPublished && resource.url?.href) {438this.doEmitResourceRequested(requestId);439}440}441442const isNavigation = requestId === loaderId && type === 'Document';443if (isNavigation) {444this.emit('navigation-response', {445frameId,446browserRequestId: requestId,447status: response.status,448location: response.headers.location,449url: response.url,450loaderId: event.loaderId,451});452}453}454455private onNetworkRequestServedFromCache(event: RequestServedFromCacheEvent): void {456const { requestId } = event;457const resource = this.requestsById.get(requestId);458if (resource) {459resource.browserServedFromCache = 'memory';460setTimeout(() => this.emitLoaded(requestId), 500).unref();461}462}463464private onLoadingFailed(event: LoadingFailedEvent): void {465const { requestId, canceled, blockedReason, errorText } = event;466467const resource = this.requestsById.get(requestId);468if (resource) {469if (!resource.url || !resource.requestTime) {470return;471}472473if (canceled) resource.browserCanceled = true;474if (blockedReason) resource.browserBlockedReason = blockedReason;475if (errorText) resource.browserLoadFailure = errorText;476477if (!this.requestPublishingById.get(requestId)?.isPublished) {478this.doEmitResourceRequested(requestId);479}480481this.emit('resource-failed', {482resource,483});484this.redirectsById.delete(requestId);485this.requestsById.delete(requestId);486this.requestPublishingById.delete(requestId);487}488}489490private onLoadingFinished(event: LoadingFinishedEvent): void {491const { requestId } = event;492this.emitLoaded(requestId);493}494495private emitLoaded(id: string): void {496const resource = this.requestsById.get(id);497if (resource) {498if (!this.requestPublishingById.get(id)?.isPublished) this.emitResourceRequested(id);499this.requestsById.delete(id);500this.requestPublishingById.delete(id);501const loaderId = this.navigationRequestIdsToLoaderId.get(id);502if (this.redirectsById.has(id)) {503for (const redirect of this.redirectsById.get(id)) {504this.emit('resource-loaded', {505resource: redirect,506frameId: redirect.frameId,507loaderId,508// eslint-disable-next-line require-await509body: async () => Buffer.from(''),510});511}512this.redirectsById.delete(id);513}514const body = this.downloadRequestBody.bind(this, id);515this.emit('resource-loaded', { resource, frameId: resource.frameId, loaderId, body });516}517}518519private async downloadRequestBody(requestId: string): Promise<Buffer> {520if (this.isChromeRetainingResources === false || !this.devtools.isConnected()) {521return null;522}523524try {525const body = await this.devtools.send('Network.getResponseBody', {526requestId,527});528return Buffer.from(body.body, body.base64Encoded ? 'base64' : undefined);529} catch (e) {530return null;531}532}533534private getPublishingForRequestId(id: string, createIfNull = false): IResourcePublishing {535const publishing = this.requestPublishingById.get(id);536if (publishing) return publishing;537if (createIfNull) {538this.requestPublishingById.set(id, { hasRequestWillBeSentEvent: false });539return this.requestPublishingById.get(id);540}541}542/////// WEBSOCKET EVENT HANDLERS /////////////////////////////////////////////////////////////////543544private onWebsocketHandshake(handshake: WebSocketWillSendHandshakeRequestEvent): void {545this.emit('websocket-handshake', {546browserRequestId: handshake.requestId,547headers: handshake.request.headers,548});549}550551private onWebsocketFrame(552isFromServer: boolean,553event: WebSocketFrameSentEvent | WebSocketFrameReceivedEvent,554): void {555const browserRequestId = event.requestId;556const { opcode, payloadData } = event.response;557const message = opcode === 1 ? payloadData : Buffer.from(payloadData, 'base64');558this.emit('websocket-frame', {559message,560browserRequestId,561isFromServer,562});563}564}565566567