-
Notifications
You must be signed in to change notification settings - Fork 311
/
Copy pathchrome.js
314 lines (298 loc) · 10.9 KB
/
chrome.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
'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();
this._handleConnectionClose();
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, [], {
maxPayload: 256 * 1024 * 1024,
perMessageDeflate: false,
followRedirects: true,
});
} 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._handleConnectionClose();
this.emit('disconnect');
});
this._ws.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 = {};
}
// 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;