'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'); 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; // locals this._notifier = notifier; this._callbacks = {}; 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 WebSocket error : new ProtocolError(request, response) ); } else { fulfill(response); } }); }); } } close(callback) { 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(); } }; if (typeof callback === 'function') { closeWebSocket(callback); return undefined; } else { return new Promise((fulfill, reject) => { closeWebSocket(fulfill); }); } } // initiate the connection process async _start() { const options = { host: this.host, port: this.port, secure: this.secure, useHostName: this.useHostName, alterPath: this.alterPath }; try { // fetch the WebSocket debugger URL const url = await this._fetchDebuggerURL(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._connectToWebSocket(); // 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 _fetchDebuggerURL(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; return await devtools.Protocol(options); } } // establish the WebSocket connection and start processing user commands _connectToWebSocket() { return new Promise((fulfill, reject) => { // create the WebSocket try { if (this.secure) { this.webSocketUrl = this.webSocketUrl.replace(/^ws:/i, 'wss:'); } this._ws = new WebSocket(this.webSocketUrl); } catch (err) { // handles bad URLs reject(err); return; } // set up event handlers this._ws.on('open', () => { fulfill(); }); this._ws.on('message', (data) => { const message = JSON.parse(data); this._handleMessage(message); }); this._ws.on('close', (code) => { this.emit('disconnect'); }); this._ws.on('error', (err) => { reject(err); }); }); } // handle the messages read from the WebSocket _handleMessage(message) { // command response if (message.id) { const callback = this._callbacks[message.id]; if (!callback) { return; } // 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]; // 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); } } // 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._ws.send(JSON.stringify(message), (err) => { if (err) { // handle low-level WebSocket errors if (typeof callback === 'function') { callback(err); } } else { this._callbacks[id] = callback; } }); } } module.exports = Chrome;