bidi_network.js

// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

const { BeforeRequestSent, ResponseStarted, FetchError } = require('./networkTypes')
const { AddInterceptParameters } = require('./addInterceptParameters')
const { ContinueResponseParameters } = require('./continueResponseParameters')
const { ContinueRequestParameters } = require('./continueRequestParameters')
const { ProvideResponseParameters } = require('./provideResponseParameters')

const NetworkEvent = {
  BEFORE_REQUEST_SENT: 'network.beforeRequestSent',
  RESPONSE_STARTED: 'network.responseStarted',
  RESPONSE_COMPLETED: 'network.responseCompleted',
  AUTH_REQUIRED: 'network.authRequired',
  FETCH_ERROR: 'network.fetchError',
}

/**
 * Represents all commands and events of Network module.
 * Described in https://w3c.github.io/webdriver-bidi/#module-network.
 */
class Network {
  #callbackId = 0
  #listener

  /**
   * Represents a Network object.
   * @constructor
   * @param {Driver} driver - The driver to fetch the BiDi connection.
   * @param {Array} browsingContextIds - An array of browsing context IDs that the network events will be subscribed to.
   */
  constructor(driver, browsingContextIds) {
    this._driver = driver
    this._browsingContextIds = browsingContextIds
    this.#listener = new Map()
    this.#listener.set(NetworkEvent.AUTH_REQUIRED, new Map())
    this.#listener.set(NetworkEvent.BEFORE_REQUEST_SENT, new Map())
    this.#listener.set(NetworkEvent.FETCH_ERROR, new Map())
    this.#listener.set(NetworkEvent.RESPONSE_STARTED, new Map())
    this.#listener.set(NetworkEvent.RESPONSE_COMPLETED, new Map())
  }

  addCallback(eventType, callback) {
    const id = ++this.#callbackId

    const eventCallbackMap = this.#listener.get(eventType)
    eventCallbackMap.set(id, callback)
    return id
  }

  removeCallback(id) {
    let hasId = false
    for (const [, callbacks] of this.#listener) {
      if (callbacks.has(id)) {
        callbacks.delete(id)
        hasId = true
      }
    }

    if (!hasId) {
      throw Error(`Callback with id ${id} not found`)
    }
  }

  invokeCallbacks(eventType, data) {
    const callbacks = this.#listener.get(eventType)
    if (callbacks) {
      for (const [, callback] of callbacks) {
        callback(data)
      }
    }
  }

  async init() {
    this.bidi = await this._driver.getBidi()
  }

  /**
   * Subscribes to the 'network.beforeRequestSent' event and handles it with the provided callback.
   *
   * @param {Function} callback - The callback function to handle the event.
   * @returns {Promise<void>} - A promise that resolves when the subscription is successful.
   */
  async beforeRequestSent(callback) {
    await this.subscribeAndHandleEvent('network.beforeRequestSent', callback)
  }

  /**
   * Subscribes to the 'network.responseStarted' event and handles it with the provided callback.
   *
   * @param {Function} callback - The callback function to handle the event.
   * @returns {Promise<void>} - A promise that resolves when the subscription is successful.
   */
  async responseStarted(callback) {
    await this.subscribeAndHandleEvent('network.responseStarted', callback)
  }

  /**
   * Subscribes to the 'network.responseCompleted' event and handles it with the provided callback.
   *
   * @param {Function} callback - The callback function to handle the event.
   * @returns {Promise<void>} - A promise that resolves when the subscription is successful.
   */
  async responseCompleted(callback) {
    await this.subscribeAndHandleEvent('network.responseCompleted', callback)
  }

  /**
   * Subscribes to the 'network.authRequired' event and handles it with the provided callback.
   *
   * @param {Function} callback - The callback function to handle the event.
   * @returns {Promise<number>} - A promise that resolves when the subscription is successful.
   */
  async authRequired(callback) {
    return await this.subscribeAndHandleEvent('network.authRequired', callback)
  }

  /**
   * Subscribes to the 'network.fetchError' event and handles it with the provided callback.
   *
   * @param {Function} callback - The callback function to handle the event.
   * @returns {Promise<void>} - A promise that resolves when the subscription is successful.
   */
  async fetchError(callback) {
    await this.subscribeAndHandleEvent('network.fetchError', callback)
  }

  async subscribeAndHandleEvent(eventType, callback) {
    if (this._browsingContextIds != null) {
      await this.bidi.subscribe(eventType, this._browsingContextIds)
    } else {
      await this.bidi.subscribe(eventType)
    }
    let id = this.addCallback(eventType, callback)

    this.ws = await this.bidi.socket
    this.ws.on('message', (event) => {
      const { params } = JSON.parse(Buffer.from(event.toString()))
      if (params) {
        let response = null
        if ('initiator' in params) {
          response = new BeforeRequestSent(
            params.context,
            params.navigation,
            params.redirectCount,
            params.request,
            params.timestamp,
            params.initiator,
          )
        } else if ('response' in params) {
          response = new ResponseStarted(
            params.context,
            params.navigation,
            params.redirectCount,
            params.request,
            params.timestamp,
            params.response,
          )
        } else if ('errorText' in params) {
          response = new FetchError(
            params.context,
            params.navigation,
            params.redirectCount,
            params.request,
            params.timestamp,
            params.errorText,
          )
        }
        this.invokeCallbacks(eventType, response)
      }
    })
    return id
  }

  /**
   * Adds a network intercept.
   *
   * @param {AddInterceptParameters} params - The parameters for the network intercept.
   * @returns {Promise<string>} - A promise that resolves to the added intercept's id.
   * @throws {Error} - If params is not an instance of AddInterceptParameters.
   */
  async addIntercept(params) {
    if (!(params instanceof AddInterceptParameters)) {
      throw new Error(`Params must be an instance of AddInterceptParameters. Received:'${params}'`)
    }

    const command = {
      method: 'network.addIntercept',
      params: Object.fromEntries(params.asMap()),
    }

    let response = await this.bidi.send(command)

    return response.result.intercept
  }

  /**
   * Removes an intercept.
   *
   * @param {string} interceptId - The ID of the intercept to be removed.
   * @returns {Promise<void>} - A promise that resolves when the intercept is successfully removed.
   */
  async removeIntercept(interceptId) {
    const command = {
      method: 'network.removeIntercept',
      params: { intercept: interceptId },
    }

    await this.bidi.send(command)
  }

  /**
   * Continues the network request with authentication credentials.
   * @param {string} requestId - The ID of the request to continue.
   * @param {string} username - The username for authentication.
   * @param {string} password - The password for authentication.
   * @returns {Promise<void>} - A promise that resolves when the command is sent.
   */
  async continueWithAuth(requestId, username, password) {
    const command = {
      method: 'network.continueWithAuth',
      params: {
        request: requestId.toString(),
        action: 'provideCredentials',
        credentials: {
          type: 'password',
          username: username,
          password: password,
        },
      },
    }
    await this.bidi.send(command)
  }

  /**
   * Fails a network request.
   *
   * @param {number} requestId - The ID of the request to fail.
   * @returns {Promise<void>} - A promise that resolves when the command is sent.
   */
  async failRequest(requestId) {
    const command = {
      method: 'network.failRequest',
      params: {
        request: requestId.toString(),
      },
    }
    await this.bidi.send(command)
  }

  /**
   * Continues the network request with authentication but without providing credentials.
   * @param {string} requestId - The ID of the request to continue with authentication.
   * @returns {Promise<void>} - A promise that resolves when the command is sent.
   */
  async continueWithAuthNoCredentials(requestId) {
    const command = {
      method: 'network.continueWithAuth',
      params: {
        request: requestId.toString(),
        action: 'default',
      },
    }
    await this.bidi.send(command)
  }

  /**
   * Cancels the authentication for a specific request.
   *
   * @param {string} requestId - The ID of the request to cancel authentication for.
   * @returns {Promise<void>} - A promise that resolves when the command is sent.
   */
  async cancelAuth(requestId) {
    const command = {
      method: 'network.continueWithAuth',
      params: {
        request: requestId.toString(),
        action: 'cancel',
      },
    }
    await this.bidi.send(command)
  }

  /**
   * Continues the network request with the provided parameters.
   *
   * @param {ContinueRequestParameters} params - The parameters for continuing the request.
   * @throws {Error} If params is not an instance of ContinueRequestParameters.
   * @returns {Promise<void>} A promise that resolves when the command is sent.
   */
  async continueRequest(params) {
    if (!(params instanceof ContinueRequestParameters)) {
      throw new Error(`Params must be an instance of ContinueRequestParameters. Received:'${params}'`)
    }

    const command = {
      method: 'network.continueRequest',
      params: Object.fromEntries(params.asMap()),
    }

    await this.bidi.send(command)
  }

  /**
   * Continues the network response with the given parameters.
   *
   * @param {ContinueResponseParameters} params - The parameters for continuing the response.
   * @throws {Error} If params is not an instance of ContinueResponseParameters.
   * @returns {Promise<void>} A promise that resolves when the command is sent.
   */
  async continueResponse(params) {
    if (!(params instanceof ContinueResponseParameters)) {
      throw new Error(`Params must be an instance of ContinueResponseParameters. Received:'${params}'`)
    }

    const command = {
      method: 'network.continueResponse',
      params: Object.fromEntries(params.asMap()),
    }

    await this.bidi.send(command)
  }

  /**
   * Provides a response for the network.
   *
   * @param {ProvideResponseParameters} params - The parameters for providing the response.
   * @throws {Error} If params is not an instance of ProvideResponseParameters.
   * @returns {Promise<void>} A promise that resolves when the command is sent.
   */
  async provideResponse(params) {
    if (!(params instanceof ProvideResponseParameters)) {
      throw new Error(`Params must be an instance of ProvideResponseParameters. Received:'${params}'`)
    }

    const command = {
      method: 'network.provideResponse',
      params: Object.fromEntries(params.asMap()),
    }

    await this.bidi.send(command)
  }

  /**
   * Unsubscribes from network events for all browsing contexts.
   * @returns {Promise<void>} A promise that resolves when the network connection is closed.
   */
  async close() {
    if (
      this._browsingContextIds !== null &&
      this._browsingContextIds !== undefined &&
      this._browsingContextIds.length > 0
    ) {
      await this.bidi.unsubscribe(
        'network.beforeRequestSent',
        'network.responseStarted',
        'network.responseCompleted',
        'network.authRequired',
        this._browsingContextIds,
      )
    } else {
      await this.bidi.unsubscribe(
        'network.beforeRequestSent',
        'network.responseStarted',
        'network.responseCompleted',
        'network.authRequired',
      )
    }
  }
}

async function getNetworkInstance(driver, browsingContextIds = null) {
  let instance = new Network(driver, browsingContextIds)
  await instance.init()
  return instance
}

module.exports = getNetworkInstance