blob: 76a8874d2b19982968bfdee838d8070c673289eb [file] [log] [blame]
/*
* Copyright (C) 2011 Google Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google Inc. nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/**
* @typedef {string}
* @suppress {checkTypes}
*/
export const ProtocolError = Symbol('Protocol.Error');
export const DevToolsStubErrorCode = -32015;
// TODO(dgozman): we are not reporting generic errors in tests, but we should
// instead report them and just have some expected errors in test expectations.
const _GenericError = -32000;
const _ConnectionClosedErrorCode = -32001;
/**
* @unrestricted
*/
export default class InspectorBackend {
constructor() {
/** @type {!Map<string, !_AgentPrototype>} */
this._agentPrototypes = new Map();
/** @type {!Map<string, !_DispatcherPrototype>} */
this._dispatcherPrototypes = new Map();
this._initialized = false;
}
/**
* @param {string} error
* @param {!Object} messageObject
*/
static reportProtocolError(error, messageObject) {
console.error(error + ': ' + JSON.stringify(messageObject));
}
/**
* @return {boolean}
*/
isInitialized() {
return this._initialized;
}
/**
* @param {string} domain
*/
_addAgentGetterMethodToProtocolTargetPrototype(domain) {
let upperCaseLength = 0;
while (upperCaseLength < domain.length && domain[upperCaseLength].toLowerCase() !== domain[upperCaseLength]) {
++upperCaseLength;
}
const methodName = domain.substr(0, upperCaseLength).toLowerCase() + domain.slice(upperCaseLength) + 'Agent';
/**
* @this {TargetBase}
*/
function agentGetter() {
return this._agents[domain];
}
TargetBase.prototype[methodName] = agentGetter;
/**
* @this {TargetBase}
*/
function registerDispatcher(dispatcher) {
this.registerDispatcher(domain, dispatcher);
}
TargetBase.prototype['register' + domain + 'Dispatcher'] = registerDispatcher;
}
/**
* @param {string} domain
* @return {!_AgentPrototype}
*/
_agentPrototype(domain) {
if (!this._agentPrototypes.has(domain)) {
this._agentPrototypes.set(domain, new _AgentPrototype(domain));
this._addAgentGetterMethodToProtocolTargetPrototype(domain);
}
return this._agentPrototypes.get(domain);
}
/**
* @param {string} domain
* @return {!_DispatcherPrototype}
*/
_dispatcherPrototype(domain) {
if (!this._dispatcherPrototypes.has(domain)) {
this._dispatcherPrototypes.set(domain, new _DispatcherPrototype());
}
return this._dispatcherPrototypes.get(domain);
}
/**
* @param {string} method
* @param {!Array.<!Object>} signature
* @param {!Array.<string>} replyArgs
* @param {boolean} hasErrorData
*/
registerCommand(method, signature, replyArgs, hasErrorData) {
const domainAndMethod = method.split('.');
this._agentPrototype(domainAndMethod[0]).registerCommand(domainAndMethod[1], signature, replyArgs, hasErrorData);
this._initialized = true;
}
/**
* @param {string} type
* @param {!Object} values
*/
registerEnum(type, values) {
const domainAndName = type.split('.');
const domain = domainAndName[0];
if (!Protocol[domain]) {
Protocol[domain] = {};
}
Protocol[domain][domainAndName[1]] = values;
this._initialized = true;
}
/**
* @param {string} eventName
* @param {!Object} params
*/
registerEvent(eventName, params) {
const domain = eventName.split('.')[0];
this._dispatcherPrototype(domain).registerEvent(eventName, params);
this._initialized = true;
}
/**
* @param {function(T)} clientCallback
* @param {string} errorPrefix
* @param {function(new:T,S)=} constructor
* @param {T=} defaultValue
* @return {function(?string, S)}
* @template T,S
*/
wrapClientCallback(clientCallback, errorPrefix, constructor, defaultValue) {
/**
* @param {?string} error
* @param {S} value
* @template S
*/
function callbackWrapper(error, value) {
if (error) {
console.error(errorPrefix + error);
clientCallback(defaultValue);
return;
}
if (constructor) {
clientCallback(new constructor(value));
} else {
clientCallback(value);
}
}
return callbackWrapper;
}
}
/** @type {function():!Connection} */
let _factory;
/**
* @interface
*/
export class Connection {
constructor() {
/** @type {?function(!Object)} */
this._onMessage;
}
/**
* @param {function((!Object|string))} onMessage
*/
setOnMessage(onMessage) {
}
/**
* @param {function(string)} onDisconnect
*/
setOnDisconnect(onDisconnect) {
}
/**
* @param {string} message
*/
sendRawMessage(message) {
}
/**
* @return {!Promise}
*/
disconnect() {
}
/**
* @param {function():!Connection} factory
*/
static setFactory(factory) {
_factory = factory;
}
/**
* @return {function():!Connection}
*/
static getFactory() {
return _factory;
}
}
const test = {
/**
* This will get called for every protocol message.
* Protocol.test.dumpProtocol = console.log
* @type {?function(string)}
*/
dumpProtocol: null,
/**
* Runs a function when no protocol activity is present.
* Protocol.test.deprecatedRunAfterPendingDispatches(() => console.log('done'))
* @type {?function(function()=)}
*/
deprecatedRunAfterPendingDispatches: null,
/**
* Sends a raw message over main connection.
* Protocol.test.sendRawMessage('Page.enable', {}, console.log)
*/
sendRawMessage: null,
/**
* Set to true to not log any errors.
*/
suppressRequestErrors: false,
/**
* Set to get notified about any messages sent over protocol.
* @type {?function({domain: string, method: string, params: !Object, id: number}, ?TargetBase)}
*/
onMessageSent: null,
/**
* Set to get notified about any messages received over protocol.
* @type {?function(!Object, ?TargetBase)}
*/
onMessageReceived: null,
};
class SessionRouter {
/**
* @param {!Connection} connection
*/
constructor(connection) {
this._connection = connection;
this._lastMessageId = 1;
this._pendingResponsesCount = 0;
this._domainToLogger = new Map();
/** @type {!Map<string, {target: !TargetBase, callbacks: !Map<number, !Protocol._Callback>, proxyConnection: ?Connection}>} */
this._sessions = new Map();
/** @type {!Array<function()>} */
this._pendingScripts = [];
test.deprecatedRunAfterPendingDispatches = this._deprecatedRunAfterPendingDispatches.bind(this);
test.sendRawMessage = this._sendRawMessageForTesting.bind(this);
this._connection.setOnMessage(this._onMessage.bind(this));
this._connection.setOnDisconnect(reason => {
const session = this._sessions.get('');
if (session) {
session.target.dispose(reason);
}
});
}
/**
* @param {!TargetBase} target
* @param {string} sessionId
* @param {?Connection} proxyConnection
*/
registerSession(target, sessionId, proxyConnection) {
// Only the Audits panel uses proxy connections. If it is ever possible to have multiple active at the
// same time, it should be tested thoroughly.
if (proxyConnection) {
for (const session of this._sessions.values()) {
if (session.proxyConnection) {
console.error('Multiple simultaneous proxy connections are currently unsupported');
break;
}
}
}
this._sessions.set(sessionId, {target, callbacks: new Map(), proxyConnection});
}
/**
* @param {string} sessionId
*/
unregisterSession(sessionId) {
const session = this._sessions.get(sessionId);
for (const callback of session.callbacks.values()) {
SessionRouter.dispatchConnectionError(callback);
}
this._sessions.delete(sessionId);
}
/**
* @param {string} sessionId
* @return {?TargetBase}
*/
_getTargetBySessionId(sessionId) {
const session = this._sessions.get(sessionId ? sessionId : '');
if (!session) {
return null;
}
return session.target;
}
/**
* @return {number}
*/
_nextMessageId() {
return this._lastMessageId++;
}
/**
* @return {!Connection}
*/
connection() {
return this._connection;
}
/**
* @param {string} sessionId
* @param {string} domain
* @param {string} method
* @param {?Object} params
* @param {!Protocol._Callback} callback
*/
sendMessage(sessionId, domain, method, params, callback) {
const messageObject = {};
const messageId = this._nextMessageId();
messageObject.id = messageId;
messageObject.method = method;
if (params) {
messageObject.params = params;
}
if (sessionId) {
messageObject.sessionId = sessionId;
}
if (test.dumpProtocol) {
test.dumpProtocol('frontend: ' + JSON.stringify(messageObject));
}
if (test.onMessageSent) {
const paramsObject = JSON.parse(JSON.stringify(params || {}));
test.onMessageSent(
{domain, method, params: /** @type {!Object} */ (paramsObject), id: messageId},
this._getTargetBySessionId(sessionId));
}
++this._pendingResponsesCount;
this._sessions.get(sessionId).callbacks.set(messageId, callback);
this._connection.sendRawMessage(JSON.stringify(messageObject));
}
/**
* @param {string} method
* @param {?Object} params
* @param {?function(...*)} callback
*/
_sendRawMessageForTesting(method, params, callback) {
const domain = method.split('.')[0];
this.sendMessage('', domain, method, params, callback || (() => {}));
}
/**
* @param {!Object|string} message
*/
_onMessage(message) {
if (test.dumpProtocol) {
test.dumpProtocol('backend: ' + ((typeof message === 'string') ? message : JSON.stringify(message)));
}
if (test.onMessageReceived) {
const messageObjectCopy = JSON.parse((typeof message === 'string') ? message : JSON.stringify(message));
test.onMessageReceived(
/** @type {!Object} */ (messageObjectCopy), this._getTargetBySessionId(messageObjectCopy.sessionId));
}
const messageObject = /** @type {!Object} */ ((typeof message === 'string') ? JSON.parse(message) : message);
// Send all messages to proxy connections.
let suppressUnknownMessageErrors = false;
for (const session of this._sessions.values()) {
if (!session.proxyConnection) {
continue;
}
if (!session.proxyConnection._onMessage) {
Protocol.InspectorBackend.reportProtocolError(
'Protocol Error: the session has a proxyConnection with no _onMessage', messageObject);
continue;
}
session.proxyConnection._onMessage(messageObject);
suppressUnknownMessageErrors = true;
}
const sessionId = messageObject.sessionId || '';
const session = this._sessions.get(sessionId);
if (!session) {
if (!suppressUnknownMessageErrors) {
Protocol.InspectorBackend.reportProtocolError(
'Protocol Error: the message with wrong session id', messageObject);
}
return;
}
// If this message is directly for the target controlled by the proxy connection, don't handle it.
if (session.proxyConnection) {
return;
}
if (session.target._needsNodeJSPatching) {
Protocol.NodeURL.patch(messageObject);
}
if ('id' in messageObject) { // just a response for some request
const callback = session.callbacks.get(messageObject.id);
session.callbacks.delete(messageObject.id);
if (!callback) {
if (!suppressUnknownMessageErrors) {
Protocol.InspectorBackend.reportProtocolError('Protocol Error: the message with wrong id', messageObject);
}
return;
}
callback(messageObject.error, messageObject.result);
--this._pendingResponsesCount;
if (this._pendingScripts.length && !this._pendingResponsesCount) {
this._deprecatedRunAfterPendingDispatches();
}
} else {
if (!('method' in messageObject)) {
Protocol.InspectorBackend.reportProtocolError('Protocol Error: the message without method', messageObject);
return;
}
const method = messageObject.method.split('.');
const domainName = method[0];
if (!(domainName in session.target._dispatchers)) {
Protocol.InspectorBackend.reportProtocolError(
`Protocol Error: the message ${messageObject.method} is for non-existing domain '${domainName}'`,
messageObject);
return;
}
session.target._dispatchers[domainName].dispatch(method[1], messageObject);
}
}
/**
* @param {function()=} script
*/
_deprecatedRunAfterPendingDispatches(script) {
if (script) {
this._pendingScripts.push(script);
}
// Execute all promises.
setTimeout(() => {
if (!this._pendingResponsesCount) {
this._executeAfterPendingDispatches();
} else {
this._deprecatedRunAfterPendingDispatches();
}
}, 0);
}
_executeAfterPendingDispatches() {
if (!this._pendingResponsesCount) {
const scripts = this._pendingScripts;
this._pendingScripts = [];
for (let id = 0; id < scripts.length; ++id) {
scripts[id]();
}
}
}
/**
* @param {!Protocol._Callback} callback
*/
static dispatchConnectionError(callback) {
const error = {
message: 'Connection is closed, can\'t dispatch pending call',
code: _ConnectionClosedErrorCode,
data: null
};
setTimeout(() => callback(error, null), 0);
}
}
/**
* @unrestricted
*/
export class TargetBase {
/**
* @param {boolean} needsNodeJSPatching
* @param {?TargetBase} parentTarget
* @param {string} sessionId
* @param {?Connection} connection
*/
constructor(needsNodeJSPatching, parentTarget, sessionId, connection) {
this._needsNodeJSPatching = needsNodeJSPatching;
this._sessionId = sessionId;
if ((!parentTarget && connection) || (!parentTarget && sessionId) || (connection && sessionId)) {
throw new Error('Either connection or sessionId (but not both) must be supplied for a child target');
}
if (sessionId) {
this._router = parentTarget._router;
} else if (connection) {
this._router = new SessionRouter(connection);
} else {
this._router = new SessionRouter(_factory());
}
this._router.registerSession(this, this._sessionId);
this._agents = {};
for (const [domain, agentPrototype] of Protocol.inspectorBackend._agentPrototypes) {
this._agents[domain] = Object.create(/** @type {!_AgentPrototype} */ (agentPrototype));
this._agents[domain]._target = this;
}
this._dispatchers = {};
for (const [domain, dispatcherPrototype] of Protocol.inspectorBackend._dispatcherPrototypes) {
this._dispatchers[domain] = Object.create(/** @type {!_DispatcherPrototype} */ (dispatcherPrototype));
this._dispatchers[domain]._dispatchers = [];
}
}
/**
* @param {string} domain
* @param {!Object} dispatcher
*/
registerDispatcher(domain, dispatcher) {
if (!this._dispatchers[domain]) {
return;
}
this._dispatchers[domain].addDomainDispatcher(dispatcher);
}
/**
* @param {string} reason
*/
dispose(reason) {
this._router.unregisterSession(this._sessionId);
this._router = null;
}
/**
* @return {boolean}
*/
isDisposed() {
return !this._router;
}
markAsNodeJSForTest() {
this._needsNodeJSPatching = true;
}
/**
* @return {!SessionRouter}
*/
router() {
return this._router;
}
}
/**
* @unrestricted
*/
class _AgentPrototype {
/**
* @param {string} domain
*/
constructor(domain) {
this._replyArgs = {};
this._hasErrorData = {};
this._domain = domain;
}
/**
* @param {string} methodName
* @param {!Array.<!Object>} signature
* @param {!Array.<string>} replyArgs
* @param {boolean} hasErrorData
*/
registerCommand(methodName, signature, replyArgs, hasErrorData) {
const domainAndMethod = this._domain + '.' + methodName;
/**
* @param {...*} vararg
* @this {_AgentPrototype}
* @return {!Promise.<*>}
*/
function sendMessagePromise(vararg) {
const params = Array.prototype.slice.call(arguments);
return _AgentPrototype.prototype._sendMessageToBackendPromise.call(this, domainAndMethod, signature, params);
}
this[methodName] = sendMessagePromise;
/**
* @param {!Object} request
* @return {!Promise}
* @this {_AgentPrototype}
*/
function invoke(request) {
return this._invoke(domainAndMethod, request);
}
this['invoke_' + methodName] = invoke;
this._replyArgs[domainAndMethod] = replyArgs;
if (hasErrorData) {
this._hasErrorData[domainAndMethod] = true;
}
}
/**
* @param {string} method
* @param {!Array.<!Object>} signature
* @param {!Array.<*>} args
* @param {function(string)} errorCallback
* @return {?Object}
*/
_prepareParameters(method, signature, args, errorCallback) {
const params = {};
let hasParams = false;
for (const param of signature) {
const paramName = param['name'];
const typeName = param['type'];
const optionalFlag = param['optional'];
if (!args.length && !optionalFlag) {
errorCallback(
`Protocol Error: Invalid number of arguments for method '${method}' call. ` +
`It must have the following arguments ${JSON.stringify(signature)}'.`);
return null;
}
const value = args.shift();
if (optionalFlag && typeof value === 'undefined') {
continue;
}
if (typeof value !== typeName) {
errorCallback(
`Protocol Error: Invalid type of argument '${paramName}' for method '${method}' call. ` +
`It must be '${typeName}' but it is '${typeof value}'.`);
return null;
}
params[paramName] = value;
hasParams = true;
}
if (args.length) {
errorCallback(`Protocol Error: Extra ${args.length} arguments in a call to method '${method}'.`);
return null;
}
return hasParams ? params : null;
}
/**
* @param {string} method
* @param {!Array<!Object>} signature
* @param {!Array<*>} args
* @return {!Promise<?>}
*/
_sendMessageToBackendPromise(method, signature, args) {
let errorMessage;
/**
* @param {string} message
*/
function onError(message) {
console.error(message);
errorMessage = message;
}
const params = this._prepareParameters(method, signature, args, onError);
if (errorMessage) {
return Promise.resolve(null);
}
return new Promise((resolve, reject) => {
const callback = (error, result) => {
if (error) {
if (!test.suppressRequestErrors && error.code !== Protocol.DevToolsStubErrorCode &&
error.code !== _GenericError && error.code !== _ConnectionClosedErrorCode) {
console.error('Request ' + method + ' failed. ' + JSON.stringify(error));
reject(error);
} else {
resolve(null);
}
return;
}
const args = this._replyArgs[method];
resolve(result && args.length ? result[args[0]] : undefined);
};
if (!this._target._router) {
SessionRouter.dispatchConnectionError(callback);
} else {
this._target._router.sendMessage(this._target._sessionId, this._domain, method, params, callback);
}
});
}
/**
* @param {string} method
* @param {?Object} request
* @return {!Promise<!Object>}
*/
_invoke(method, request) {
return new Promise(fulfill => {
const callback = (error, result) => {
if (error && !test.suppressRequestErrors && error.code !== Protocol.DevToolsStubErrorCode &&
error.code !== _GenericError && error.code !== _ConnectionClosedErrorCode) {
console.error('Request ' + method + ' failed. ' + JSON.stringify(error));
}
if (!result) {
result = {};
}
if (error) {
result[Protocol.Error] = error.message;
}
fulfill(result);
};
if (!this._target._router) {
SessionRouter.dispatchConnectionError(callback);
} else {
this._target._router.sendMessage(this._target._sessionId, this._domain, method, request, callback);
}
});
}
}
/**
* @unrestricted
*/
class _DispatcherPrototype {
constructor() {
this._eventArgs = {};
}
/**
* @param {string} eventName
* @param {!Object} params
*/
registerEvent(eventName, params) {
this._eventArgs[eventName] = params;
}
/**
* @param {!Object} dispatcher
*/
addDomainDispatcher(dispatcher) {
this._dispatchers.push(dispatcher);
}
/**
* @param {string} functionName
* @param {!Object} messageObject
*/
dispatch(functionName, messageObject) {
if (!this._dispatchers.length) {
return;
}
if (!this._eventArgs[messageObject.method]) {
Protocol.InspectorBackend.reportProtocolError(
`Protocol Error: Attempted to dispatch an unspecified method '${messageObject.method}'`, messageObject);
return;
}
const params = [];
if (messageObject.params) {
const paramNames = this._eventArgs[messageObject.method];
for (let i = 0; i < paramNames.length; ++i) {
params.push(messageObject.params[paramNames[i]]);
}
}
for (let index = 0; index < this._dispatchers.length; ++index) {
const dispatcher = this._dispatchers[index];
if (functionName in dispatcher) {
dispatcher[functionName].apply(dispatcher, params);
}
}
}
}
/* Legacy exported object */
self.Protocol = self.Protocol || {};
/* Legacy exported object */
Protocol = Protocol || {};
Protocol.DevToolsStubErrorCode = DevToolsStubErrorCode;
Protocol.SessionRouter = SessionRouter;
/** @constructor */
Protocol.InspectorBackend = InspectorBackend;
/** @interface */
Protocol.Connection = Connection;
/** @type {!InspectorBackend} */
Protocol.inspectorBackend = new InspectorBackend();
Protocol.test = test;
/** @constructor */
Protocol.TargetBase = TargetBase;
/**
* Takes error and result.
* @typedef {function(?Object, ?Object)}
*/
Protocol._Callback;
/** @typedef {string} */
Protocol.Error = ProtocolError;