Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support for --remote-debugging-pipe transport #440

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 58 additions & 41 deletions lib/chrome.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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);
});
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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]);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i also considered having the user pass the writeStream and readStream directly, but figured that there is not a use case for it

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);
});
});
Expand Down Expand Up @@ -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') {
Expand Down
87 changes: 87 additions & 0 deletions lib/stdio-wrapper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use strict';

// Adapted from https://github.com/puppeteer/puppeteer/blob/7a2a41f2087b07e8ef1feaf3881bdcc3fd4922ca/src/PipeTransport.js
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this is objectionable for some reason, this can be rewritten from scratch, it's not too complicated


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