From baad1bd802f4d41b3a827c623bb6e4b3b8cef545 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Tue, 29 Dec 2020 15:50:15 -0500 Subject: [PATCH 1/2] feat: support for --remote-debugging-pipe transport --- README.md | 8 +++- lib/chrome.js | 99 ++++++++++++++++++++++++++------------------ lib/stdio-wrapper.js | 87 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 43 deletions(-) create mode 100644 lib/stdio-wrapper.js diff --git a/README.md b/README.md index 8c612e0..035201d 100644 --- a/README.md +++ b/README.md @@ -439,8 +439,12 @@ Connects to a remote instance using the [Chrome Debugging Protocol]. - `protocol`: [Chrome Debugging Protocol] descriptor object. Defaults to use the protocol chosen according to the `local` option; - `local`: a boolean indicating whether the protocol must be fetched *remotely* - or if the local version must be used. It has no effect if the `protocol` - option is set. Defaults to `false`. + or if the local version must be used. It has no effect if the `protocol` or + `process` option is set. Defaults to `false`. +- `process`: a `ChildProcess` object that represents a Chrome instance launched + with `--remote-debugging-pipe`. If passed, websocket-related options will be + ignored and communications will occur over stdio instead. Note: the `protocol` + cannot be fetched remotely if a `process` is passed. These options are also valid properties of all the instances of the `CDP` class. In addition to that, the `webSocketUrl` field contains the currently used diff --git a/lib/chrome.js b/lib/chrome.js index 4afaf90..c3cc52e 100644 --- a/lib/chrome.js +++ b/lib/chrome.js @@ -10,6 +10,7 @@ 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) { @@ -55,8 +56,9 @@ class Chrome extends EventEmitter { this.useHostName = !!(options.useHostName); this.alterPath = options.alterPath || ((path) => path); this.protocol = options.protocol; - this.local = !!(options.local); + this.local = !!(options.local || options.process); this.target = options.target || defaultTarget; + this.process = options.process; // locals this._notifier = notifier; this._callbacks = {}; @@ -101,26 +103,12 @@ class Chrome extends EventEmitter { } 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); + this._close(callback); return undefined; } else { return new Promise((fulfill, reject) => { - closeWebSocket(fulfill); + this._close(fulfill); }); } } @@ -135,20 +123,22 @@ class Chrome extends EventEmitter { 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; + if (!this.process) { + // 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(); + // finally connect to the WebSocket or stdio + 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 @@ -211,32 +201,59 @@ class Chrome extends EventEmitter { } } - // establish the WebSocket connection and start processing user commands - _connectToWebSocket() { + _createStdioWrapper() { + const stdio = new StdioWrapper(this.process.stdio[3], this.process.stdio[4]); + this._close = stdio.close.bind(stdio); + this._send = stdio.send.bind(stdio); + return stdio; + } + + _createWebSocketWrapper() { + if (this.secure) { + this.webSocketUrl = this.webSocketUrl.replace(/^ws:/i, 'wss:'); + } + const ws = new WebSocket(this.webSocketUrl); + 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(); + callback(); + }); + ws.close(); + } + }; + this._send = ws.send.bind(ws); + return ws; + } + + // establish the connection wrapper and start processing user commands + _connect() { return new Promise((fulfill, reject) => { - // create the WebSocket + let wrapper; try { - if (this.secure) { - this.webSocketUrl = this.webSocketUrl.replace(/^ws:/i, 'wss:'); - } - this._ws = new WebSocket(this.webSocketUrl); + wrapper = this.process ? this._createStdioWrapper() : this._createWebSocketWrapper(); } catch (err) { - // handles bad URLs + // handle missing stdio streams, bad URLs... reject(err); return; } // set up event handlers - this._ws.on('open', () => { + wrapper.on('open', () => { fulfill(); }); - this._ws.on('message', (data) => { + wrapper.on('message', (data) => { const message = JSON.parse(data); this._handleMessage(message); }); - this._ws.on('close', (code) => { + wrapper.on('close', (code) => { this.emit('disconnect'); }); - this._ws.on('error', (err) => { + wrapper.on('error', (err) => { reject(err); }); }); @@ -278,7 +295,7 @@ class Chrome extends EventEmitter { id, method, params: params || {} }; - this._ws.send(JSON.stringify(message), (err) => { + this._send(JSON.stringify(message), (err) => { if (err) { // handle low-level WebSocket errors if (typeof callback === 'function') { diff --git a/lib/stdio-wrapper.js b/lib/stdio-wrapper.js new file mode 100644 index 0000000..2ca288c --- /dev/null +++ b/lib/stdio-wrapper.js @@ -0,0 +1,87 @@ +'use strict'; + +// Adapted from https://github.com/puppeteer/puppeteer/blob/7a2a41f2087b07e8ef1feaf3881bdcc3fd4922ca/src/PipeTransport.js + +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed 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('events'); + +function addEventListener(emitter, eventName, handler) { + emitter.on(eventName, handler); + return { emitter, eventName, handler }; +} + +function removeEventListeners(listeners) { + for (const listener of listeners) + listener.emitter.removeListener(listener.eventName, listener.handler); + listeners.length = 0; +} + +// wrapper for null-terminated stdio message transport +class StdioWrapper extends EventEmitter { + constructor(pipeWrite, pipeRead) { + super(); + this._pipeWrite = pipeWrite; + this._pendingMessage = ''; + this._eventListeners = [ + addEventListener(pipeRead, 'data', buffer => this._dispatch(buffer)), + addEventListener(pipeRead, 'close', () => this.emit('close')), + addEventListener(pipeRead, 'error', (err) => this.emit('error', err)), + addEventListener(pipeWrite, 'error', (err) => this.emit('error', err)), + ]; + process.nextTick(() => { + this.emit('open'); + }); + } + + send(message, callback) { + try { + this._pipeWrite.write(message); + this._pipeWrite.write('\0'); + callback(); + } catch (err) { + callback(err); + } + } + + _dispatch(buffer) { + let end = buffer.indexOf('\0'); + if (end === -1) { + this._pendingMessage += buffer.toString(); + return; + } + const message = this._pendingMessage + buffer.toString(undefined, 0, end); + + this.emit('message', message); + + let start = end + 1; + end = buffer.indexOf('\0', start); + while (end !== -1) { + this.emit('message', buffer.toString(undefined, start, end)); + start = end + 1; + end = buffer.indexOf('\0', start); + } + this._pendingMessage = buffer.toString(undefined, start); + } + + close() { + this._pipeWrite = null; + removeEventListeners(this._eventListeners); + } +} + +module.exports = StdioWrapper; From 789d5ecdc97a981ac7d136318e98baa1143ec433 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Mon, 11 Jan 2021 15:00:26 -0500 Subject: [PATCH 2/2] properly call callback on close --- lib/stdio-wrapper.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/stdio-wrapper.js b/lib/stdio-wrapper.js index 2ca288c..f0370c4 100644 --- a/lib/stdio-wrapper.js +++ b/lib/stdio-wrapper.js @@ -78,9 +78,10 @@ class StdioWrapper extends EventEmitter { this._pendingMessage = buffer.toString(undefined, start); } - close() { + close(callback) { this._pipeWrite = null; removeEventListeners(this._eventListeners); + callback(); } }