lib/connection/CRIConnection.js
const util = require('util')
const Chrome = require('chrome-remote-interface/lib/chrome')
const EventEmitter = require('eventemitter3')
const WebSocket = require('ws')
const Events = require('../Events')
const CDPSession = require('./CDPSession')
const { createProtocolError, interopCRIApi } = require('../__shared')
/**
* An exact replica of puppeteer's Connection class that simply re-uses the prior art
* that is the one and only chrome-remote-interface by cyrus-and
* @since chrome-remote-interface-extra
*/
class CRIConnection extends Chrome {
/**
* @param {CRIOptions} [options]
* @return {Promise<CRIConnection>}
*/
static async connect (options) {
const notifier = new EventEmitter()
const connectOrError = new Promise((resolve, reject) => {
notifier.once('connect', resolve)
notifier.once('error', reject)
})
const connection = new CRIConnection(options, notifier)
await connectOrError
return connection
}
/**
* @param {CDPSession|Chrome|Object} session
* @return {CRIConnection|Chrome|Object}
*/
static fromSession (session) {
if (session instanceof CDPSession) {
return session._connection
}
return session
}
/**
* @param {CRIOptions} options
* @param {EventEmitter} notifier
*/
constructor (options, notifier) {
super(options, notifier)
/** @type {!Map<string, CDPSession>} */
this._sessions = new Map()
/** @type {!Map<number, {resolve: function(value: *): void, reject: function(reason: *): void, error: !Error, method: string}>} */
this._crieCallbacks = new Map()
if (this.setMaxListeners) {
this.setMaxListeners(Infinity)
}
this.on(Events.CRIClient.Disconnected, this._onClose.bind(this))
}
/**
* Get the actual event that is emitted when the connection has closed
* @return {string}
*/
get $$disconnectEvent () {
return Events.CRIConnection.Disconnected
}
/**
* @param {string} sessionId
* @return {?CDPSession}
*/
session (sessionId) {
return this._sessions.get(sessionId) || null
}
dispose () {
this._onClose()
this.close()
}
/**
* @param {Object} targetInfo
* @return {Promise<CDPSession>}
*/
async createSession (targetInfo) {
const { sessionId } = await this.send('Target.attachToTarget', {
targetId: targetInfo.targetId,
flatten: true
})
return this._sessions.get(sessionId)
}
/**
* @param {string} method - protocol method name
* @param {!Object} [params = {}] - Optional method parameters
* @return {Promise<Object>}
*/
send (method, params = {}) {
const id = this._rawSend({ method, params })
return new Promise((resolve, reject) => {
this._crieCallbacks.set(id, {
resolve,
reject,
error: new Error(),
method
})
})
}
_onClose () {
if (this._closed) return
this._closed = true
for (const session of this._sessions.values()) {
session._onClosed()
}
this._sessions.clear()
this.emit(Events.CRIClient.Disconnected)
}
/**
* Utility function for maintaining the original CRI API
* @param {string} method
* @param {Object} [params]
* @param {*} [callback]
* @return {*}
* @protected
*/
_interopSend (method, params, callback) {
return super.send(method, params, callback)
}
/**
* In order to have CDP sessions and allow them to operate as they do in Puppeteer we need to provide them a special
* method for them to send their messages and this is it :)
* @param {Object} message
* @return {number}
*/
_rawSend (message) {
const id = this._nextCommandId++
const msg = JSON.stringify(Object.assign({}, message, { id }))
this._ws.send(msg)
return id
}
/**
* A very simple override of the original _handleMessage function that adds the handling both the puppeteer
* API and the original CRI API (minus direct sends)
* @param {Object} object
* @return {*}
* @private
*/
_handleMessage (object) {
if (object.id && object.id in this._callbacks) {
return super._handleMessage(object)
}
if (object.method === 'Target.attachedToTarget') {
const sessionId = object.params.sessionId
const session = new CDPSession(
this,
object.params.targetInfo.type,
sessionId
)
this._sessions.set(sessionId, session)
} else if (object.method === 'Target.detachedFromTarget') {
const session = this._sessions.get(object.params.sessionId)
if (session) {
session._onClosed()
this._sessions.delete(object.params.sessionId)
}
}
if (object.sessionId) {
const session = this._sessions.get(object.sessionId)
if (session) {
session._onMessage(object)
}
} else if (object.id) {
const cb = this._crieCallbacks.get(object.id)
if (cb) {
this._crieCallbacks.delete(object.id)
if (object.error) {
cb.reject(createProtocolError(cb.error, cb.method, object))
} else {
cb.resolve(object.result)
}
}
} else {
this.emit(object.method, object.params)
}
}
/**
* This override really on exists to turn off perMessageDeflate when creating the web socket
* @return {Promise<*>}
* @private
*/
_connectToWebSocket () {
return new Promise((resolve, reject) => {
// create the WebSocket
try {
if (this.secure) {
this.webSocketUrl = this.webSocketUrl.replace(/^ws:/i, 'wss:')
}
this._ws = new WebSocket(this.webSocketUrl, [], {
perMessageDeflate: false
})
} catch (err) {
// handles bad URLs
reject(err)
return
}
// set up event handlers
this._ws.on('open', () => {
resolve()
})
this._ws.on('message', data => {
const message = JSON.parse(data)
this._handleMessage(message)
})
this._ws.on('close', () => {
this.emit('disconnect')
})
this._ws.on('error', err => {
reject(err)
})
})
}
_start () {
return super._start().then(() => {
interopCRIApi(this)
})
}
/**
* @return {string}
*/
toString () {
return util.inspect(this, { depth: null })
}
/** @ignore */
// eslint-disable-next-line space-before-function-paren
[util.inspect.custom](depth, options) {
if (depth < 0) {
return options.stylize('[CRIConnection]', 'special')
}
const newOptions = Object.assign({}, options, {
depth: options.depth == null ? null : options.depth - 1
})
const inner = util.inspect(
{
webSocketUrl: this.webSocketUrl,
host: this.host,
port: this.port,
secure: this.secure,
useHostName: this.useHostName,
target: this.target,
sessions: this._sessions
},
newOptions
)
return `${options.stylize('CRIConnection', 'special')} ${inner}`
}
}
/**
* @type {CRIConnection}
*/
module.exports = CRIConnection