NeuraLegion/cypress-har-generator

View on GitHub
src/network/NetworkObserver.ts

Summary

Maintainability
C
1 day
Test Coverage
D
69%
import { Logger } from '../utils/Logger';
import { NetworkRequest } from './NetworkRequest';
import { ExtraInfoBuilder } from './ExtraInfoBuilder';
import type { NetworkObserverOptions } from './NetworkObserverOptions';
import type { Observer } from './Observer';
import type { RequestFilter } from './filters';
import type { Network, NetworkEvent } from './Network';
import type Protocol from 'devtools-protocol';
import type { Header } from 'har-format';

export class NetworkObserver implements Observer<NetworkRequest> {
  private readonly _entries: Map<Protocol.Network.RequestId, NetworkRequest>;
  private readonly _extraInfoBuilders: Map<
    Protocol.Network.RequestId,
    ExtraInfoBuilder
  >;
  private destination?: (chromeEntry: NetworkRequest) => unknown;

  get empty(): boolean {
    return this._entries.size === 0;
  }

  constructor(
    private readonly options: NetworkObserverOptions,
    private readonly network: Network,
    private readonly logger: Logger,
    private readonly requestFilter?: RequestFilter
  ) {
    this._entries = new Map<Protocol.Network.RequestId, NetworkRequest>();
    this._extraInfoBuilders = new Map<
      Protocol.Network.RequestId,
      ExtraInfoBuilder
    >();
  }

  public async subscribe(
    callback: (chromeEntry: NetworkRequest) => unknown
  ): Promise<void> {
    this.destination = callback;

    await this.network.attachToTargets((event: NetworkEvent): void =>
      this.handleEvent(event)
    );
  }

  public async unsubscribe(): Promise<void> {
    await this.network.detachFromTargets();
    delete this.destination;
    this._entries.clear();
    this._extraInfoBuilders.clear();
  }

  public signedExchangeReceived(
    params: Protocol.Network.SignedExchangeReceivedEvent
  ): void {
    const entry: NetworkRequest | undefined = this._entries.get(
      params.requestId
    );
    if (!entry) {
      return;
    }

    entry.signedExchangeInfo = params.info;
    entry.resourceType = 'SignedExchange';

    this.updateNetworkRequestWithResponse(entry, params.info.outerResponse);
  }

  public requestWillBeSent({
    type,
    loaderId,
    initiator,
    redirectResponse,
    documentURL,
    frameId,
    timestamp,
    requestId,
    request,
    wallTime
  }: Protocol.Network.RequestWillBeSentEvent): void {
    let entry: NetworkRequest | undefined = this._entries.get(requestId);

    if (entry) {
      if (!redirectResponse) {
        return;
      }

      if (!entry.signedExchangeInfo) {
        this.responseReceived({
          requestId,
          loaderId,
          timestamp,
          frameId,
          type: 'Other',
          response: redirectResponse
        });
      }
      entry = this._appendRedirect(requestId, timestamp, request.url);
    } else {
      entry = this.createRequest(
        requestId,
        frameId,
        loaderId,
        request.url,
        documentURL,
        initiator
      );
    }

    this.updateNetworkRequestWithRequest(entry, request);

    entry.setIssueTime(timestamp, wallTime);
    entry.resourceType = type ?? 'Other';

    this.getExtraInfoBuilder(requestId).addRequest(entry);

    this.startRequest(entry);
  }

  public dataReceived({
    requestId,
    dataLength,
    encodedDataLength,
    timestamp
  }: Protocol.Network.DataReceivedEvent): void {
    const entry: NetworkRequest | undefined = this._entries.get(requestId);
    if (!entry) {
      return;
    }
    entry.resourceSize += dataLength;
    if (encodedDataLength !== -1) {
      entry.increaseTransferSize(encodedDataLength);
    }
    entry.endTime = timestamp;
  }

  public responseReceived({
    requestId,
    response,
    timestamp,
    type
  }: Omit<Protocol.Network.ResponseReceivedEvent, 'hasExtraInfo'>): void {
    const entry: NetworkRequest | undefined = this._entries.get(requestId);

    if (!entry) {
      return;
    }

    entry.responseReceivedTime = timestamp;
    entry.resourceType = type;

    this.updateNetworkRequestWithResponse(entry, response);
  }

  public resourceChangedPriority({
    requestId,
    newPriority
  }: Protocol.Network.ResourceChangedPriorityEvent): void {
    const entry: NetworkRequest | undefined = this._entries.get(requestId);

    if (!entry) {
      return;
    }

    entry.priority = newPriority;
  }

  public async loadingFinished({
    requestId,
    timestamp,
    encodedDataLength
  }: Protocol.Network.LoadingFinishedEvent): Promise<void> {
    const entry: NetworkRequest | undefined = this._entries.get(requestId);

    if (!entry) {
      return;
    }

    this.finishRequest(entry, timestamp, encodedDataLength);
  }

  public loadingFailed({
    requestId,
    errorText,
    canceled,
    type,
    timestamp
  }: Protocol.Network.LoadingFailedEvent): void {
    const entry: NetworkRequest | undefined = this._entries.get(requestId);
    if (!entry) {
      return;
    }

    entry.resourceType = type;

    this.finishRequest(entry, timestamp, -1);

    const message = errorText || (canceled && 'Canceled');
    this.logger.debug(`Failed request: ${requestId}. Reason: ${message}`);
  }

  public webSocketCreated({
    initiator,
    requestId,
    url
  }: Protocol.Network.WebSocketCreatedEvent): void {
    const entry: NetworkRequest = this.createRequest(
      requestId,
      '',
      '',
      url,
      '',
      initiator
    );
    this.startRequest(entry);
  }

  public eventSourceMessageReceived({
    requestId,
    timestamp,
    eventName,
    eventId,
    data
  }: Protocol.Network.EventSourceMessageReceivedEvent): void {
    const entry: NetworkRequest | undefined = this._entries.get(requestId);
    if (!entry) {
      return;
    }
    entry.addEventSourceMessage(timestamp, eventName, eventId, data);
  }

  public webSocketWillSendHandshakeRequest({
    request,
    requestId,
    timestamp,
    wallTime
  }: Protocol.Network.WebSocketWillSendHandshakeRequestEvent): void {
    const entry: NetworkRequest | undefined = this._entries.get(requestId);
    if (!entry) {
      return;
    }
    entry.requestMethod = 'GET';
    entry.requestHeaders = this.headersMapToHeadersArray(request.headers);
    entry.setIssueTime(timestamp, wallTime);
  }

  public webSocketHandshakeResponseReceived({
    timestamp,
    response,
    requestId
  }: Protocol.Network.WebSocketHandshakeResponseReceivedEvent): void {
    const entry: NetworkRequest | undefined = this._entries.get(requestId);

    if (!entry) {
      return;
    }

    entry.statusCode = response.status;
    entry.statusText = response.statusText;
    entry.responseHeaders = this.headersMapToHeadersArray(response.headers);
    entry.responseHeadersText = response.headersText || '';

    if (response.requestHeaders) {
      entry.requestHeaders = this.headersMapToHeadersArray(
        response.requestHeaders
      );
    }

    if (response.requestHeadersText) {
      entry.requestHeadersText = response.requestHeadersText;
    }

    entry.responseReceivedTime = timestamp;
    entry.protocol = 'websocket';
  }

  public webSocketFrameSent({
    requestId,
    timestamp,
    response
  }: Protocol.Network.WebSocketFrameSentEvent): void {
    const entry: NetworkRequest | undefined = this._entries.get(requestId);

    if (!entry) {
      return;
    }

    entry.addProtocolFrame(response, timestamp, true);
    entry.responseReceivedTime = timestamp;
  }

  public webSocketFrameReceived({
    requestId,
    timestamp,
    response
  }: Protocol.Network.WebSocketFrameReceivedEvent): void {
    const entry: NetworkRequest | undefined = this._entries.get(requestId);

    if (!entry) {
      return;
    }

    entry.addProtocolFrame(response, timestamp, false);
    entry.responseReceivedTime = timestamp;
  }

  public webSocketFrameError({
    errorMessage,
    requestId,
    timestamp
  }: Protocol.Network.WebSocketFrameErrorEvent): void {
    const entry: NetworkRequest | undefined = this._entries.get(requestId);

    if (!entry) {
      return;
    }

    entry.addProtocolFrameError(errorMessage, timestamp);
    entry.responseReceivedTime = timestamp;
  }

  public webSocketClosed({
    requestId,
    timestamp
  }: Protocol.Network.WebSocketClosedEvent): void {
    const entry: NetworkRequest | undefined = this._entries.get(requestId);

    if (!entry) {
      return;
    }

    this.finishRequest(entry, timestamp, -1);
  }

  public requestWillBeSentExtraInfo({
    requestId,
    headers
  }: Protocol.Network.RequestWillBeSentExtraInfoEvent): void {
    this.getExtraInfoBuilder(requestId).addRequestExtraInfo({
      requestHeaders: this.headersMapToHeadersArray(headers)
    });
  }

  public responseReceivedExtraInfo({
    requestId,
    headers,
    headersText
  }: Protocol.Network.ResponseReceivedExtraInfoEvent): void {
    this.getExtraInfoBuilder(requestId).addResponseExtraInfo({
      responseHeaders: this.headersMapToHeadersArray(headers),
      responseHeadersText: headersText
    });
  }

  private getExtraInfoBuilder(
    requestId: Protocol.Network.RequestId
  ): ExtraInfoBuilder {
    if (!this._extraInfoBuilders.has(requestId)) {
      this._extraInfoBuilders.set(
        requestId,
        new ExtraInfoBuilder((): void => {
          this._extraInfoBuilders.delete(requestId);
        })
      );
    }

    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    return this._extraInfoBuilders.get(requestId)!;
  }

  // eslint-disable-next-line @typescript-eslint/naming-convention
  private _appendRedirect(
    requestId: Protocol.Network.RequestId,
    time: Protocol.Network.MonotonicTime,
    redirectURL: string
  ): NetworkRequest {
    const originalNetworkRequest: NetworkRequest = this._entries.get(
      requestId
    ) as NetworkRequest;

    let redirectCount = 0;
    let redirect: NetworkRequest | undefined =
      originalNetworkRequest.redirectSource;

    while (redirect) {
      redirectCount++;
      redirect = redirect.redirectSource;
    }

    originalNetworkRequest.markAsRedirect(redirectCount);

    this.finishRequest(originalNetworkRequest, time, -1);

    const newNetworkRequest: NetworkRequest = this.createRequest(
      requestId,
      originalNetworkRequest.frameId,
      originalNetworkRequest.loaderId,
      redirectURL,
      originalNetworkRequest.documentURL,
      originalNetworkRequest.initiator
    );

    newNetworkRequest.redirectSource = originalNetworkRequest;

    return newNetworkRequest;
  }

  private finishRequest(
    networkRequest: NetworkRequest,
    finishTime: Protocol.Network.MonotonicTime,
    encodedDataLength: number
  ): void {
    networkRequest.endTime = finishTime;

    if (encodedDataLength >= 0) {
      const redirectSource: NetworkRequest | undefined =
        networkRequest.redirectSource;

      if (redirectSource?.signedExchangeInfo) {
        networkRequest.transferSize = 0;
        redirectSource.transferSize = encodedDataLength;
      } else {
        networkRequest.transferSize = encodedDataLength;
      }
    }

    this.loadContent(networkRequest);

    this.getExtraInfoBuilder(networkRequest.requestId).finished();

    if (!this.shouldExcludeRequest(networkRequest)) {
      networkRequest
        .waitForCompletion()
        .then(() => this.destination?.(networkRequest))
        .finally(() => this._entries.delete(networkRequest.requestId));
    }
  }

  private loadContent(networkRequest: NetworkRequest): void {
    if (networkRequest.mimeType && this.options.content) {
      networkRequest.setContentData(
        this.network.getResponseBody(networkRequest.requestId)
      );
    }
  }

  private startRequest(networkRequest: NetworkRequest): void {
    this._entries.set(networkRequest.requestId, networkRequest);
  }

  private updateNetworkRequestWithRequest(
    chromeRequest: NetworkRequest,
    request: Protocol.Network.Request
  ): void {
    chromeRequest.requestMethod = request.method;
    chromeRequest.requestHeaders = this.headersMapToHeadersArray(
      request.headers
    );
    chromeRequest.setRequestFormData(
      request.hasPostData
        ? this.getRequestPostData(chromeRequest, request)
        : Promise.resolve(undefined)
    );
    chromeRequest.initialPriority = request.initialPriority;
  }

  private getRequestPostData(
    request: NetworkRequest,
    rawRequest: Protocol.Network.Request
  ): Promise<string | undefined> {
    return rawRequest.postData !== undefined
      ? Promise.resolve(rawRequest.postData)
      : this.network
          .getRequestBody(request.requestId)
          .then(
            ({
              postData
            }: Protocol.Network.GetRequestPostDataResponse): string => postData
          )
          .catch(() => undefined);
  }

  private createRequest(
    requestId: Protocol.Network.RequestId,
    frameId: string | undefined,
    loaderId: Protocol.Network.LoaderId,
    url: string,
    documentURL: string,
    initiator?: Protocol.Network.Initiator
  ): NetworkRequest {
    return new NetworkRequest(
      requestId,
      url,
      documentURL,
      loaderId,
      initiator,
      frameId
    );
  }

  // eslint-disable-next-line complexity
  private updateNetworkRequestWithResponse(
    networkRequest: NetworkRequest,
    response: Protocol.Network.Response
  ): void {
    if (response.url && networkRequest.url !== response.url) {
      networkRequest.url = response.url;
    }
    networkRequest.mimeType = response.mimeType;
    networkRequest.statusCode = response.status;
    networkRequest.statusText = response.statusText;

    if (!networkRequest.hasExtraResponseInfo) {
      networkRequest.responseHeaders = this.headersMapToHeadersArray(
        response.headers
      );
    }

    if (response.encodedDataLength >= 0) {
      networkRequest.transferSize = response.encodedDataLength;
    }

    if (response.requestHeaders && !networkRequest.hasExtraRequestInfo) {
      networkRequest.requestHeaders = this.headersMapToHeadersArray(
        response.requestHeaders
      );
      networkRequest.requestHeadersText = response.requestHeadersText ?? '';
    }

    networkRequest.connectionReused = response.connectionReused;
    networkRequest.connectionId = String(response.connectionId);

    if (response.remoteIPAddress) {
      networkRequest.setRemoteAddress(
        response.remoteIPAddress,
        response.remotePort || -1
      );
    }

    if (response.timing) {
      networkRequest.timing = response.timing;
    }

    networkRequest.protocol = response.protocol ?? '';
  }

  private headersMapToHeadersArray(
    headersMap: Protocol.Network.Headers
  ): Header[] {
    return Object.keys(headersMap).reduce(
      (acc: Header[], name: string): Header[] => {
        const values: string[] = headersMap[name].split('\n');

        acc.push(...values.map((value: string): Header => ({ name, value })));

        return acc;
      },
      []
    );
  }

  private shouldExcludeRequest(request: NetworkRequest): boolean {
    return this.requestFilter?.wouldApply(this.options)
      ? !this.requestFilter.apply(request, this.options)
      : false;
  }

  private handleEvent({ method, params, sessionId }: NetworkEvent): void {
    const methodName = method.substring(method.indexOf('.') + 1);

    if (typeof this[methodName] === 'function') {
      this[methodName](params, sessionId);
    }
  }
}