// 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