UNPKG

@luminati-io/chrome-remote-interface

Version:
363 lines (344 loc) 13.1 kB
'use strict'; const EventEmitter = require('events'); const util = require('util'); const formatUrl = require('url').format; const parseUrl = require('url').parse; const WebSocket = require('ws'); const api = require('./api.js'); const defaults = require('./defaults.js'); const devtools = require('./devtools.js'); const StdioWrapper = require('./stdio-wrapper.js'); class ProtocolError extends Error { constructor(request, response) { let {message} = response; if (response.data) { message += ` (${response.data})`; } super(message); // attach the original response as well this.request = request; this.response = response; } } class Chrome extends EventEmitter { constructor(options, notifier) { super(); // options const defaultTarget = (targets) => { // prefer type = 'page' inspectable targets as they represents // browser tabs (fall back to the first inspectable target // otherwise) let backup; let target = targets.find((target) => { if (target.webSocketDebuggerUrl) { backup = backup || target; return target.type === 'page'; } else { return false; } }); target = target || backup; if (target) { return target; } else { throw new Error('No inspectable targets'); } }; options = options || {}; this.host = options.host || defaults.HOST; this.port = options.port || defaults.PORT; this.secure = !!(options.secure); this.useHostName = !!(options.useHostName); this.alterPath = options.alterPath || ((path) => path); this.protocol = options.protocol; this.local = !!(options.local); this.target = options.target || defaultTarget; this.connectOptions = options.connectOptions; this.process = options.process; // locals this._notifier = notifier; this._callbacks = {}; this._callbacks_start_ts = {}; this._callbacks_req_ctx = {}; this._nextCommandId = 1; // properties this.webSocketUrl = undefined; // operations this._start(); } // avoid misinterpreting protocol's members as custom util.inspect functions inspect(depth, options) { options.customInspect = false; return util.inspect(this, options); } send(method, params, sessionId, callback) { // handle optional arguments const optionals = Array.from(arguments).slice(1); params = optionals.find(x => typeof x === 'object'); sessionId = optionals.find(x => typeof x === 'string'); callback = optionals.find(x => typeof x === 'function'); // return a promise when a callback is not provided if (typeof callback === 'function') { this._enqueueCommand(method, params, sessionId, callback); return undefined; } else { return new Promise((fulfill, reject) => { this._enqueueCommand(method, params, sessionId, (error, response) => { if (error) { const request = {method, params, sessionId}; reject( error instanceof Error ? error // low-level io error : new ProtocolError(request, response) ); } else { fulfill(response); } }); }); } } close(callback) { if (typeof callback === 'function') { this._close(callback); return undefined; } else { return new Promise((fulfill, reject) => { this._close(fulfill); }); } } // initiate the connection process async _start() { try { if (this.process) { // we first "connect" to stdio pipes, so that we can // first the protocol remotely via the pipe. await this._connect(); const protocol = await this._fetchProtocol({}); api.prepare(this, protocol); } else { const options = { host: this.host, port: this.port, secure: this.secure, useHostName: this.useHostName, alterPath: this.alterPath, ...this.connectOptions, }; // fetch the WebSocket debugger URL const url = await this._fetchWsDebuggerURL(options); // allow the user to alter the URL const urlObject = parseUrl(url); urlObject.pathname = options.alterPath(urlObject.pathname); this.webSocketUrl = formatUrl(urlObject); // update the connection parameters using the debugging URL options.host = urlObject.hostname; options.port = urlObject.port || options.port; // fetch the protocol and prepare the API const protocol = await this._fetchProtocol(options); api.prepare(this, protocol); // finally connect to the WebSocket await this._connect(); } // since the handler is executed synchronously, the emit() must be // performed in the next tick so that uncaught errors in the client code // are not intercepted by the Promise mechanism and therefore reported // via the 'error' event process.nextTick(() => { this._notifier.emit('connect', this); }); } catch (err) { this._notifier.emit('error', err); } } // fetch the WebSocket URL according to 'target' async _fetchWsDebuggerURL(options) { const userTarget = this.target; switch (typeof userTarget) { case 'string': { let idOrUrl = userTarget; // use default host and port if omitted (and a relative URL is specified) if (idOrUrl.startsWith('/')) { idOrUrl = `ws://${this.host}:${this.port}${idOrUrl}`; } // a WebSocket URL is specified by the user (e.g., node-inspector) if (idOrUrl.match(/^wss?:/i)) { return idOrUrl; // done! } // a target id is specified by the user else { const targets = await devtools.List(options); const object = targets.find((target) => target.id === idOrUrl); return object.webSocketDebuggerUrl; } } case 'object': { const object = userTarget; return object.webSocketDebuggerUrl; } case 'function': { const func = userTarget; const targets = await devtools.List(options); const result = func(targets); const object = typeof result === 'number' ? targets[result] : result; return object.webSocketDebuggerUrl; } default: throw new Error(`Invalid target argument "${this.target}"`); } } // fetch the protocol according to 'protocol' and 'local' async _fetchProtocol(options) { // if a protocol has been provided then use it if (this.protocol) { return this.protocol; } // otherwise user either the local or the remote version else { options.local = this.local; if (this.process) options.cdp = this; return await devtools.Protocol(options); } } _createStdioWrapper() { const stdio = new StdioWrapper(this.process.stdio[3], this.process.stdio[4]); this._close = (...args)=>stdio.close(...args); this._send = (...args)=>stdio.send(...args); return stdio; } _createWebSocketWrapper() { if (this.secure) { this.webSocketUrl = this.webSocketUrl.replace(/^ws:/i, 'wss:'); } const ws = this._ws = new WebSocket(this.webSocketUrl, [], { followRedirects: true, ...this.connectOptions, }); this._close = (callback) => { // don't close if it's already closed if (ws.readyState === 3) { callback(); } else { // don't notify on user-initiated shutdown ('disconnect' event) ws.removeAllListeners('close'); ws.once('close', () => { ws.removeAllListeners(); this._handleConnectionClose(); callback(); }); ws.close(); } }; this._send = (...args)=>ws.send(...args); return ws; } // establish the connection wrapper and start processing user commands _connect() { return new Promise((fulfill, reject) => { try { this._transport = this.process ? this._createStdioWrapper() : this._createWebSocketWrapper(); } catch (err) { // handle missing stdio streams, bad URLs... reject(err); return; } // set up event handlers this._transport.on('open', () => { fulfill(); }); this._transport.on('message', (data) => { const message = JSON.parse(data); this._handleMessage(message, data.length); }); this._transport.on('close', (code) => { this._handleConnectionClose(); this.emit('disconnect'); }); this._transport.on('error', (err) => { reject(err); }); }); } _handleConnectionClose() { // make sure to complete all the unresolved callbacks const err = new Error('WebSocket connection closed'); for (const callback of Object.values(this._callbacks)) { callback(err); } this._callbacks = {}; this._callbacks_start_ts = {}; this._callbacks_req_ctx = {}; } // handle the messages read from the WebSocket _handleMessage(message, size) { // command response if (message.id) { const callback = this._callbacks[message.id]; if (!callback) { return; } const duration = Date.now()-this._callbacks_start_ts[message.id]; if (message.duration) this.emit('cmd_rtt', duration-message.duration); const ctx = this._callbacks_req_ctx[message.id]; if (ctx) { this.emit('cmd_rx_size', {method: ctx.method, size}, ctx.sessionId); } // interpret the lack of both 'error' and 'result' as success // (this may happen with node-inspector) if (message.error) { callback(true, message.error); } else { callback(false, message.result || {}); } // unregister command response callback delete this._callbacks[message.id]; delete this._callbacks_start_ts[message.id]; delete this._callbacks_req_ctx[message.id]; // notify when there are no more pending commands if (Object.keys(this._callbacks).length === 0) { this.emit('ready'); } } // event else if (message.method) { const {method, params, sessionId} = message; this.emit('event', message); this.emit(method, params, sessionId); this.emit(`${method}.${sessionId}`, params, sessionId); this.emit('cmd_rx_size', {method, size}, sessionId); } } // send a command to the remote endpoint and register a callback for the reply _enqueueCommand(method, params, sessionId, callback) { const id = this._nextCommandId++; const message = { id, method, sessionId, params: params || {} }; this._send(JSON.stringify(message), (err) => { if (err) { // handle low-level WebSocket errors if (typeof callback === 'function') { callback(err); } } else { this._callbacks[id] = callback; this._callbacks_start_ts[id] = Date.now(); } }); } get transport() { return this._transport; } } module.exports = Chrome;