bidi_index.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 { EventEmitter } = require('node:events')
const WebSocket = require('ws')

const RESPONSE_TIMEOUT = 1000 * 30

class Index extends EventEmitter {
  id = 0
  connected = false
  events = []
  browsingContexts = []

  /**
   * Create a new websocket connection
   * @param _webSocketUrl
   */
  constructor(_webSocketUrl) {
    super()
    this.connected = false
    this._ws = new WebSocket(_webSocketUrl)
    this._ws.on('open', () => {
      this.connected = true
    })
  }

  /**
   * @returns {WebSocket}
   */
  get socket() {
    return this._ws
  }

  /**
   * @returns {boolean|*}
   */
  get isConnected() {
    return this.connected
  }

  /**
   * Get Bidi Status
   * @returns {Promise<*>}
   */
  get status() {
    return this.send({
      method: 'session.status',
      params: {},
    })
  }

  /**
   * Resolve connection
   * @returns {Promise<unknown>}
   */
  async waitForConnection() {
    return new Promise((resolve) => {
      if (this.connected) {
        resolve()
      } else {
        this._ws.once('open', () => {
          resolve()
        })
      }
    })
  }

  /**
   * Sends a bidi request
   * @param params
   * @returns {Promise<unknown>}
   */
  async send(params) {
    if (!this.connected) {
      await this.waitForConnection()
    }

    const id = ++this.id

    this._ws.send(JSON.stringify({ id, ...params }))

    return new Promise((resolve, reject) => {
      const timeoutId = setTimeout(() => {
        reject(new Error(`Request with id ${id} timed out`))
        handler.off('message', listener)
      }, RESPONSE_TIMEOUT)

      const listener = (data) => {
        try {
          const payload = JSON.parse(data.toString())
          if (payload.id === id) {
            clearTimeout(timeoutId)
            handler.off('message', listener)
            resolve(payload)
          }
        } catch (err) {
          // eslint-disable-next-line no-undef
          log.error(`Failed parse message: ${err.message}`)
        }
      }

      const handler = this._ws.on('message', listener)
    })
  }

  /**
   * Subscribe to events
   * @param events
   * @param browsingContexts
   * @returns {Promise<void>}
   */
  async subscribe(events, browsingContexts) {
    function toArray(arg) {
      if (arg === undefined) {
        return []
      }

      return Array.isArray(arg) ? [...arg] : [arg]
    }

    const eventsArray = toArray(events)
    const contextsArray = toArray(browsingContexts)

    const params = {
      method: 'session.subscribe',
      params: {},
    }

    if (eventsArray.length && eventsArray.some((event) => typeof event !== 'string')) {
      throw new TypeError('events should be string or string array')
    }

    if (contextsArray.length && contextsArray.some((context) => typeof context !== 'string')) {
      throw new TypeError('browsingContexts should be string or string array')
    }

    if (eventsArray.length) {
      params.params.events = eventsArray
    }

    if (contextsArray.length) {
      params.params.contexts = contextsArray
    }

    this.events.push(...eventsArray)

    await this.send(params)
  }

  /**
   * Unsubscribe to events
   * @param events
   * @param browsingContexts
   * @returns {Promise<void>}
   */
  async unsubscribe(events, browsingContexts) {
    const eventsToRemove = typeof events === 'string' ? [events] : events

    // Check if the eventsToRemove are in the subscribed events array
    // Filter out events that are not in this.events before filtering
    const existingEvents = eventsToRemove.filter((event) => this.events.includes(event))

    // Remove the events from the subscribed events array
    this.events = this.events.filter((event) => !existingEvents.includes(event))

    if (typeof browsingContexts === 'string') {
      this.browsingContexts.pop()
    } else if (Array.isArray(browsingContexts)) {
      this.browsingContexts = this.browsingContexts.filter((id) => !browsingContexts.includes(id))
    }

    if (existingEvents.length === 0) {
      return
    }
    const params = {
      method: 'session.unsubscribe',
      params: {
        events: existingEvents,
      },
    }

    if (this.browsingContexts.length > 0) {
      params.params.contexts = this.browsingContexts
    }

    await this.send(params)
  }

  /**
   * Close ws connection.
   * @returns {Promise<unknown>}
   */
  close() {
    const closeWebSocket = (callback) => {
      // don't close if it's already closed
      if (this._ws.readyState === 3) {
        callback()
      } else {
        // don't notify on user-initiated shutdown ('disconnect' event)
        this._ws.removeAllListeners('close')
        this._ws.once('close', () => {
          this._ws.removeAllListeners()
          callback()
        })
        this._ws.close()
      }
    }
    return new Promise((fulfill, _) => {
      closeWebSocket(fulfill)
    })
  }
}

/**
 * API
 * @type {function(*): Promise<Index>}
 */
module.exports = Index