NeuraLegion/cypress-har-generator

View on GitHub
src/network/NetworkRequest.ts

Summary

Maintainability
D
2 days
Test Coverage
A
95%
import { CookieParser } from './CookieParser';
import { NetworkCookie } from './NetworkCookie';
import { StringUtils } from '../utils/StringUtils';
import type { RequestExtraInfo, ResponseExtraInfo } from './ExtraInfoBuilder';
import type { Header, Param, QueryString } from 'har-format';
import type Protocol from 'devtools-protocol';

export interface ContentData {
  error?: string;
  text?: string;
  encoding?: string;
}

export enum WebSocketFrameType {
  REQUEST = 'request',
  RESPONSE = 'response',
  ERROR = 'error'
}

export interface WebSocket {
  type: WebSocketFrameType;
  data: string;
  time: Protocol.Network.MonotonicTime;
  opcode: number;
  mask: boolean;
}

export interface EventSourceMessage {
  time: number;
  eventName: string;
  eventId: string;
  data: string;
}

export class NetworkRequest {
  private _contentData?: Promise<ContentData>;
  private _wallIssueTime: Protocol.Network.TimeSinceEpoch = -1;
  private _requestHeaderValues: Map<string, string> = new Map<string, string>();
  private _responseHeaderValues: Map<string, string> = new Map<
    string,
    string
  >();
  private _parsedQueryParameters?: QueryString[];
  private _currentPriority?: Protocol.Network.ResourcePriority;
  private _requestFormData: Promise<string | undefined> =
    Promise.resolve(undefined);
  private _formParametersPromise?: Promise<Param[]>;

  private _signedExchangeInfo?: Protocol.Network.SignedExchangeInfo;

  // TODO: use to finalize a response in the `requestWillBeSent` event handler or
  //  update `transferSize` for the chain of redirects
  get signedExchangeInfo() {
    return this._signedExchangeInfo;
  }

  set signedExchangeInfo(info) {
    this._signedExchangeInfo = info;
  }

  private _hasExtraResponseInfo: boolean = false;

  get hasExtraResponseInfo(): boolean {
    return this._hasExtraResponseInfo;
  }

  set hasExtraResponseInfo(value: boolean) {
    this._hasExtraResponseInfo = value;
  }

  private _hasExtraRequestInfo: boolean = false;

  get hasExtraRequestInfo(): boolean {
    return this._hasExtraRequestInfo;
  }

  set hasExtraRequestInfo(value: boolean) {
    this._hasExtraRequestInfo = value;
  }

  private _connectionId: string = '0';

  get connectionId() {
    return this._connectionId;
  }

  set connectionId(value: string) {
    this._connectionId = value;
  }

  private _protocol: string = '';

  get protocol(): string {
    return this._protocol;
  }

  set protocol(value: string) {
    this._protocol = value ?? '';
  }

  private _requestTime: number = 0;

  get requestTime(): number {
    return this._requestTime;
  }

  set requestTime(value: number) {
    this._requestTime = value ?? 0;
  }

  private _requestMethod: string = '';

  get requestMethod(): string {
    return this._requestMethod;
  }

  set requestMethod(value: string) {
    this._requestMethod = value ?? '';
  }

  private _statusText: string = '';

  get statusText(): string {
    return this._statusText;
  }

  set statusText(value: string) {
    this._statusText = value ?? '';
  }

  private _parsedURL!: URL;

  get parsedURL() {
    return this._parsedURL;
  }

  private _url!: string;

  get url(): string {
    return this._url;
  }

  set url(value: string) {
    if (this._url === value) {
      return;
    }

    this._url = value;
    this._parsedURL = new URL(value);
    delete this._queryString;
    delete this._parsedQueryParameters;
  }

  private _remoteAddress: string = '';

  get remoteAddress(): string {
    return this._remoteAddress;
  }

  private _startTime: Protocol.Network.MonotonicTime = -1;

  get startTime(): Protocol.Network.MonotonicTime {
    return this._startTime || -1;
  }

  private _issueTime: Protocol.Network.MonotonicTime = -1;

  get issueTime(): Protocol.Network.MonotonicTime {
    return this._issueTime;
  }

  private _endTime: number = -1;

  get endTime(): number {
    return this._endTime || -1;
  }

  set endTime(x: number) {
    if (this.timing && this.timing.requestTime) {
      this._endTime = Math.max(x, this.responseReceivedTime);
    } else {
      this._endTime = x;
      if (this._responseReceivedTime > x) {
        this._responseReceivedTime = x;
      }
    }
  }

  private _responseReceivedTime: number = -1;

  get responseReceivedTime(): number {
    return this._responseReceivedTime || -1;
  }

  set responseReceivedTime(value: number) {
    this._responseReceivedTime = value;
  }

  private _resourceSize: number = 0;

  get resourceSize(): number {
    return this._resourceSize || 0;
  }

  set resourceSize(value: number) {
    this._resourceSize = value ?? 0;
  }

  private _transferSize: number = 0;

  get transferSize(): number {
    return this._transferSize || 0;
  }

  set transferSize(value: number) {
    this._transferSize = value ?? 0;
  }

  private _timing?: Protocol.Network.ResourceTiming;

  get timing() {
    return this._timing;
  }

  set timing(timingInfo) {
    if (!timingInfo) {
      return;
    }

    this._startTime = timingInfo.requestTime;

    const headersReceivedTime: number =
      timingInfo.requestTime + timingInfo.receiveHeadersEnd / 1000.0;

    if (
      (this._responseReceivedTime || -1) < 0 ||
      this._responseReceivedTime > headersReceivedTime
    ) {
      this._responseReceivedTime = headersReceivedTime;
    }

    if (this._startTime > this._responseReceivedTime) {
      this._responseReceivedTime = this._startTime;
    }

    this._timing = timingInfo;
  }

  private _mimeType?: string;

  get mimeType() {
    return this._mimeType;
  }

  set mimeType(value) {
    this._mimeType = value;
  }

  private _resourceType: Protocol.Network.ResourceType = 'Other';

  get resourceType(): Protocol.Network.ResourceType {
    return this._resourceType;
  }

  set resourceType(resourceType: Protocol.Network.ResourceType) {
    this._resourceType = resourceType ?? 'Other';
  }

  private _redirectSource?: NetworkRequest;

  get redirectSource() {
    return this._redirectSource;
  }

  set redirectSource(originatingRequest) {
    this._redirectSource = originatingRequest;
  }

  private _requestHeaders: Header[] = [];

  get requestHeaders(): Header[] {
    return this._requestHeaders;
  }

  set requestHeaders(headers: Header[]) {
    this._requestHeaders = headers;
    this._requestHeaderValues.clear();
    delete this._requestCookies;
  }

  private _requestCookies?: NetworkCookie[];

  get requestCookies(): NetworkCookie[] | undefined {
    if (!this._requestCookies) {
      const cookie = this.requestHeaderValue('Cookie');
      this._requestCookies = cookie
        ? new CookieParser().parseCookie(cookie)
        : undefined;
    }

    return this._requestCookies;
  }

  get contentLength(): number {
    const contentLength: string | undefined =
      this.requestHeaderValue('Content-Length');

    return contentLength == null || isNaN(+contentLength)
      ? 0
      : parseInt(contentLength, 10);
  }

  private _requestHeadersText: string = '';

  get requestHeadersText(): string {
    return this._requestHeadersText;
  }

  set requestHeadersText(text: string) {
    this._requestHeadersText = text;
  }

  private _connectionReused: boolean = false;

  get connectionReused(): boolean {
    return this._connectionReused;
  }

  set connectionReused(value: boolean) {
    this._connectionReused = value;
  }

  private _responseHeaders: Header[] = [];

  get responseHeaders(): Header[] {
    return this._responseHeaders || [];
  }

  set responseHeaders(value: Header[]) {
    this._responseHeaders = value;
    delete this._responseCookies;
    this._responseHeaderValues.clear();
  }

  private _responseHeadersText: string = '';

  get responseHeadersText(): string {
    return this._responseHeadersText;
  }

  set responseHeadersText(value: string) {
    this._responseHeadersText = value;
  }

  private _responseCookies?: NetworkCookie[];

  get responseCookies(): NetworkCookie[] | undefined {
    if (!this._responseCookies) {
      const cookie = this.responseHeaderValue('Set-Cookie');
      this._responseCookies = cookie
        ? new CookieParser().parseSetCookie(cookie)
        : undefined;
    }

    return this._responseCookies;
  }

  private _queryString?: string;

  get queryString() {
    if (this._queryString || !this.url) {
      return this._queryString;
    }

    let queryString: string | undefined;
    const questionMarkPosition = this.url.indexOf('?');

    if (questionMarkPosition !== -1) {
      queryString = this.url.substring(questionMarkPosition + 1);
      const hashSignPosition = queryString.indexOf('#');

      if (hashSignPosition !== -1) {
        queryString = queryString.substring(0, hashSignPosition);
      }
    }

    this._queryString = queryString;

    return this._queryString;
  }

  private _initialPriority?: Protocol.Network.ResourcePriority;

  get initialPriority() {
    return this._initialPriority;
  }

  set initialPriority(priority) {
    this._initialPriority = priority;
  }

  private _eventSourceMessages: EventSourceMessage[] = [];

  get eventSourceMessages(): EventSourceMessage[] {
    return this._eventSourceMessages;
  }

  private _frames: WebSocket[] = [];

  get frames(): WebSocket[] {
    return this._frames;
  }

  private _statusCode: number = 0;

  get statusCode(): number {
    return this._statusCode;
  }

  set statusCode(value: number) {
    this._statusCode = value;
  }

  get requestId(): Protocol.Network.RequestId {
    return this._requestId;
  }

  get requestHttpVersion(): string {
    if (this.requestHeadersText) {
      const firstLine = this.requestHeadersText.split(/\r\n/)[0];
      const match = firstLine.match(/(HTTP\/\d+\.\d+)$/);

      return match ? match[1] : 'HTTP/0.9';
    }

    const version =
      this.requestHeaderValue('version') || this.requestHeaderValue(':version');
    if (version) {
      return version;
    }

    return this.getFilteredProtocolName();
  }

  get queryParameters(): QueryString[] | undefined {
    if (this._parsedQueryParameters) {
      return this._parsedQueryParameters;
    }

    if (this.queryString) {
      this._parsedQueryParameters = this.parseParameters(this.queryString);
    }

    return this._parsedQueryParameters;
  }

  get requestContentType(): string | undefined {
    return this.requestHeaderValue('Content-Type');
  }

  get priority(): Protocol.Network.ResourcePriority | undefined {
    return this._currentPriority ?? this.initialPriority ?? undefined;
  }

  set priority(priority) {
    this._currentPriority = priority;
  }

  constructor(
    // eslint-disable-next-line @typescript-eslint/naming-convention
    private _requestId: Protocol.Network.RequestId,
    url: string,
    public readonly documentURL: string,
    public readonly loaderId: Protocol.Network.LoaderId,
    public readonly initiator?: Protocol.Network.Initiator,
    public readonly frameId: Protocol.Page.FrameId = ''
  ) {
    this.url = url;
  }

  public async waitForCompletion(): Promise<void> {
    await Promise.all([this._contentData, this._formParametersPromise]);
  }

  public isBlob(): boolean {
    return this._url.startsWith('blob:');
  }

  public setRemoteAddress(ip: string, port: number): void {
    this._remoteAddress = `${ip}:${port}`;
  }

  public setIssueTime(
    monotonicTime: Protocol.Network.MonotonicTime,
    wallTime: Protocol.Network.TimeSinceEpoch
  ): void {
    this._issueTime = monotonicTime;
    this._wallIssueTime = wallTime;
    this._startTime = monotonicTime;
  }

  public increaseTransferSize(value: number): void {
    this._transferSize = (this._transferSize || 0) + value;
  }

  public requestFormData(): Promise<string | undefined> {
    return this._requestFormData;
  }

  public setRequestFormData(data: string | Promise<string | undefined>): void {
    this._requestFormData =
      typeof data === 'string' ? Promise.resolve(data) : data;
    this._formParametersPromise = undefined;
  }

  public getWallTime(monotonicTime: Protocol.Network.MonotonicTime): number {
    return this._wallIssueTime
      ? this._wallIssueTime - this._issueTime + monotonicTime
      : monotonicTime;
  }

  public formParameters(): Promise<Param[]> {
    if (!this._formParametersPromise) {
      this._formParametersPromise = this.parseFormParameters();
    }

    return this._formParametersPromise;
  }

  public responseHttpVersion(): string {
    if (this._responseHeadersText) {
      const firstLine: string = this._responseHeadersText.split(/\r\n/)[0];
      const match: RegExpMatchArray | null =
        firstLine.match(/^(HTTP\/\d+\.\d+)/);

      return match ? match[1] : 'HTTP/0.9';
    }

    const version =
      this.responseHeaderValue('version') ||
      this.responseHeaderValue(':version');

    if (version) {
      return version;
    }

    return this.getFilteredProtocolName();
  }

  public setContentData(
    data: Promise<Protocol.Network.GetResponseBodyResponse>
  ): void {
    if (this.resourceType === 'WebSocket') {
      this._contentData = Promise.resolve({
        error: 'Content for WebSockets is currently not supported'
      });

      return;
    }

    this._contentData = data
      .then(
        ({
          body: text,
          base64Encoded
        }: Protocol.Network.GetResponseBodyResponse): ContentData => ({
          text,
          encoding: base64Encoded ? 'base64' : undefined
        })
      )
      .catch((e: Error): ContentData => ({ error: e.message }));
  }

  public contentData(): Promise<ContentData> | undefined {
    return this._contentData;
  }

  public addProtocolFrameError(
    errorMessage: string,
    time: Protocol.Network.MonotonicTime
  ): void {
    this.addFrame({
      time,
      type: WebSocketFrameType.ERROR,
      data: errorMessage,
      opcode: -1,
      mask: false
    });
  }

  public addProtocolFrame(
    response: Protocol.Network.WebSocketFrame,
    time: Protocol.Network.MonotonicTime,
    sent: boolean
  ): void {
    const type: WebSocketFrameType = sent
      ? WebSocketFrameType.REQUEST
      : WebSocketFrameType.RESPONSE;

    this.addFrame({
      type,
      time,
      data: response.payloadData,
      opcode: response.opcode,
      mask: response.mask
    });
  }

  public addEventSourceMessage(
    time: number,
    eventName: string,
    eventId: string,
    data: string
  ) {
    const message = { time, eventName, eventId, data };
    this._eventSourceMessages.push(message);
  }

  public markAsRedirect(redirectCount: number): void {
    this._requestId = `${this.requestId}:redirected.${redirectCount}`;
  }

  public addExtraRequestInfo(extraRequestInfo: RequestExtraInfo): void {
    this.requestHeaders = extraRequestInfo.requestHeaders;
    this._hasExtraRequestInfo = true;
    this.requestHeadersText = '';
  }

  public addExtraResponseInfo(extraResponseInfo: ResponseExtraInfo): void {
    this.responseHeaders = extraResponseInfo.responseHeaders;

    if (extraResponseInfo.responseHeadersText) {
      this.responseHeadersText = extraResponseInfo.responseHeadersText;

      if (this.requestHeadersText) {
        let requestHeadersText = `${this._requestMethod} ${this.parsedURL.pathname}`;

        // eslint-disable-next-line max-depth
        if (this.parsedURL.search) {
          requestHeadersText += this.parsedURL.search;
        }

        requestHeadersText += ` HTTP/1.1\r\n`;

        // eslint-disable-next-line max-depth
        for (const { name, value } of this.requestHeaders) {
          requestHeadersText += `${name}: ${value}\r\n`;
        }

        this.requestHeadersText = requestHeadersText;
      }
    }

    this._hasExtraResponseInfo = true;
  }

  public responseHeaderValue(headerName: string): string | undefined {
    if (!this._responseHeaderValues.has(headerName)) {
      const headerValue = this.computeHeaderValue(
        this.responseHeaders,
        headerName
      );
      headerValue && this._responseHeaderValues.set(headerName, headerValue);
    }

    return this._responseHeaderValues.get(headerName);
  }

  private async parseFormParameters(): Promise<Param[]> {
    if (
      this.requestContentType?.match(
        /^application\/x-www-form-urlencoded\s*(;.*)?$/i
      )
    ) {
      const formUrlencoded = await this.requestFormData();

      if (!formUrlencoded) {
        return [];
      }

      return this.parseParameters(formUrlencoded);
    }

    const multipartDetails = this.requestContentType?.match(
      /^multipart\/form-data\s*;\s*boundary\s*=\s*(\S+)\s*$/
    );

    if (!multipartDetails) {
      return [];
    }

    const boundary = multipartDetails[1];
    const formData = await this.requestFormData();

    if (!boundary || !formData) {
      return [];
    }

    return this.parseMultipartFormDataParameters(formData, boundary);
  }

  private parseMultipartFormDataParameters(
    data: string,
    boundary: string
  ): Param[] {
    const sanitizedBoundary: string = StringUtils.escapeCharacters(boundary);
    const keyValuePattern = new RegExp(
      // Header with an optional file name.
      '^\\r\\ncontent-disposition\\s*:\\s*form-data\\s*;\\s*name="([^"]*)"(?:\\s*;\\s*filename="([^"]*)")?' +
        // Optional secondary header with the content type.
        '(?:\\r\\ncontent-type\\s*:\\s*([^\\r\\n]*))?' +
        // Padding.
        '\\r\\n\\r\\n' +
        // Value
        '(.*)' +
        // Padding.
        '\\r\\n$',
      'is'
    );
    const fields: string[] = data.split(
      // eslint-disable-next-line no-useless-escape
      new RegExp(`--${sanitizedBoundary}(?:--\s*$)?`, 'g')
    );

    return fields.reduce((result: Param[], field: string): Param[] => {
      const [match, name, fileName, contentType, value]: RegExpMatchArray =
        field.match(keyValuePattern) ?? [];

      if (!match) {
        return result;
      }

      result.push({ name, value, fileName, contentType });

      return result;
    }, []);
  }

  private addFrame(frame: WebSocket): void {
    this._frames.push(frame);
  }

  private requestHeaderValue(headerName: string): string | undefined {
    if (!this._requestHeaderValues.has(headerName)) {
      const headerValue = this.computeHeaderValue(
        this.requestHeaders,
        headerName
      );
      headerValue && this._requestHeaderValues.set(headerName, headerValue);
    }

    return this._requestHeaderValues.get(headerName);
  }

  private getFilteredProtocolName(): string {
    const protocol = this._protocol.toLowerCase();

    if (protocol === 'h2') {
      return 'http/2.0';
    }

    return protocol.replace(/^http\/2(\.0)?\+/, 'http/2.0+');
  }

  private parseParameters(queryString: string): QueryString[] {
    return queryString.split('&').map((pair: string): QueryString => {
      const position: number = pair.indexOf('=');
      if (position === -1) {
        return { name: pair, value: '' };
      } else {
        return {
          name: pair.substring(0, position),
          value: pair.substring(position + 1)
        };
      }
    });
  }

  private computeHeaderValue(
    headers: Header[],
    headerName: string
  ): string | undefined {
    headerName = headerName.toLowerCase();

    const values: string[] = headers
      .filter(({ name }: Header): boolean => name.toLowerCase() === headerName)
      .map(({ value }: Header): string => value);

    if (!values.length) {
      return undefined;
    }

    // Set-Cookie values should be separated by '\n', not comma, otherwise cookies could not be parsed.
    if (headerName === 'set-cookie') {
      return values.join('\n');
    }

    return values.join(', ');
  }
}