gecko-dev/remote/cdp/CDPConnection.sys.mjs

295 lines
9.5 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { WebSocketConnection } from "chrome://remote/content/shared/WebSocketConnection.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
Log: "chrome://remote/content/shared/Log.sys.mjs",
UnknownMethodError: "chrome://remote/content/cdp/Error.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "logger", () =>
lazy.Log.get(lazy.Log.TYPES.CDP)
);
export class CDPConnection extends WebSocketConnection {
/**
* @param {WebSocket} webSocket
* The WebSocket server connection to wrap.
* @param {Connection} httpdConnection
* Reference to the httpd.js's connection needed for clean-up.
*/
constructor(webSocket, httpdConnection) {
super(webSocket, httpdConnection);
this.sessions = new Map();
this.defaultSession = null;
}
/**
* Register a new Session to forward the messages to.
*
* A session without any `id` attribute will be considered to be the
* default one, to which messages without `sessionId` attribute are
* forwarded to. Only one such session can be registered.
*
* @param {Session} session
* The session to register.
*/
registerSession(session) {
lazy.logger.warn(
`Support for the Chrome DevTools Protocol (CDP) in Firefox will be deprecated after Firefox 128 (ESR) ` +
`and will be removed in a later release. CDP users should consider migrating ` +
`to WebDriver BiDi. See https://bugzilla.mozilla.org/show_bug.cgi?id=1872254`
);
// CDP is not compatible with Fission by default, check the appropriate
// preferences are set to ensure compatibility.
if (
Services.prefs.getIntPref("fission.webContentIsolationStrategy") !== 0 ||
Services.prefs.getBoolPref("fission.bfcacheInParent")
) {
lazy.logger.warn(
`Invalid browser preferences for CDP. Set "fission.webContentIsolationStrategy"` +
`to 0 and "fission.bfcacheInParent" to false before Firefox starts.`
);
}
if (!session.id) {
if (this.defaultSession) {
throw new Error(
"Default session is already set on Connection, " +
"can't register another one."
);
}
this.defaultSession = session;
}
this.sessions.set(session.id, session);
}
/**
* Send an error back to the CDP client.
*
* @param {number} id
* Id of the packet which lead to an error.
* @param {Error} err
* Error object with `message` and `stack` attributes.
* @param {string=} sessionId
* Id of the session used to send this packet. Falls back to the
* default session if not specified.
*/
sendError(id, err, sessionId) {
const error = {
message: err.message,
data: err.stack,
};
this.send({ id, error, sessionId });
}
/**
* Send an event coming from a Domain to the CDP client.
*
* @param {string} method
* The event name. This is composed by a domain name, a dot character
* followed by the event name, e.g. `Target.targetCreated`.
* @param {object} params
* A JSON-serializable object, which is the payload of this event.
* @param {string=} sessionId
* The sessionId from which this packet is emitted. Falls back to the
* default session if not specified.
*/
sendEvent(method, params, sessionId) {
this.send({ method, params, sessionId });
if (Services.profiler?.IsActive()) {
ChromeUtils.addProfilerMarker(
"CDP: Event",
{ category: "Remote-Protocol" },
method
);
}
// When a client attaches to a secondary target via
// `Target.attachToTarget`, we should emit an event back with the
// result including the `sessionId` attribute of this secondary target's
// session. `Target.attachToTarget` creates the secondary session and
// returns the session ID.
if (sessionId) {
// receivedMessageFromTarget is expected to send a raw CDP packet
// in the `message` property and it to be already serialized to a
// string
this.send({
method: "Target.receivedMessageFromTarget",
params: { sessionId, message: JSON.stringify({ method, params }) },
});
}
}
/**
* Interpret a given CDP packet for a given Session.
*
* @param {string} sessionId
* ID of the session for which we should execute a command.
* @param {string} message
* The stringified JSON payload of the CDP packet, which is about
* executing a Domain's function.
*/
sendMessageToTarget(sessionId, message) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session '${sessionId}' doesn't exist.`);
}
// `message` is received from `Target.sendMessageToTarget` where the
// message attribute is a stringified JSON payload which represent a CDP
// packet.
const packet = JSON.parse(message);
// The CDP packet sent by the client shouldn't have a sessionId attribute
// as it is passed as another argument of `Target.sendMessageToTarget`.
// Set it here in order to reuse the codepath of flatten session, where
// the client sends CDP packets with a `sessionId` attribute instead
// of going through the old and probably deprecated
// `Target.sendMessageToTarget` API.
packet.sessionId = sessionId;
this.onPacket(packet);
}
/**
* Send the result of a call to a Domain's function back to the CDP client.
*
* @param {number} id
* The request id being sent by the client to call the domain's method.
* @param {object} result
* A JSON-serializable object, which is the actual result.
* @param {string=} sessionId
* The sessionId from which this packet is emitted. Falls back to the
* default session if not specified.
*/
sendResult(id, result, sessionId) {
result = typeof result != "undefined" ? result : {};
this.send({ id, result, sessionId });
// When a client attaches to a secondary target via
// `Target.attachToTarget`, and it executes a command via
// `Target.sendMessageToTarget`, we should emit an event back with the
// result including the `sessionId` attribute of this secondary target's
// session. `Target.attachToTarget` creates the secondary session and
// returns the session ID.
if (sessionId) {
// receivedMessageFromTarget is expected to send a raw CDP packet
// in the `message` property and it to be already serialized to a
// string
this.send({
method: "Target.receivedMessageFromTarget",
params: { sessionId, message: JSON.stringify({ id, result }) },
});
}
}
// Transport hooks
/**
* Called by the `transport` when the connection is closed.
*/
onConnectionClose() {
// Cleanup all the registered sessions.
for (const session of this.sessions.values()) {
session.destructor();
}
this.sessions.clear();
super.onConnectionClose();
}
/**
* Receive a packet from the WebSocket layer.
*
* This packet is sent by a CDP client and is meant to execute
* a particular function on a given Domain.
*
* @param {object} packet
* JSON-serializable object sent by the client.
*/
async onPacket(packet) {
super.onPacket(packet);
const { id, method, params, sessionId } = packet;
const startTime = Cu.now();
try {
// First check for mandatory field in the packets
if (typeof id == "undefined") {
throw new TypeError("Message missing 'id' field");
}
if (typeof method == "undefined") {
throw new TypeError("Message missing 'method' field");
}
// Extract the domain name and the method name out of `method` attribute
const { domain, command } = splitMethod(method);
// If a `sessionId` field is passed, retrieve the session to which we
// should forward this packet. Otherwise send it to the default session.
let session;
if (!sessionId) {
if (!this.defaultSession) {
throw new Error("Connection is missing a default Session.");
}
session = this.defaultSession;
} else {
session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session '${sessionId}' doesn't exists.`);
}
}
// Bug 1600317 - Workaround to deny internal methods to be called
if (command.startsWith("_")) {
throw new lazy.UnknownMethodError(command);
}
// Finally, instruct the targeted session to execute the command
const result = await session.execute(id, domain, command, params);
this.sendResult(id, result, sessionId);
} catch (e) {
this.sendError(id, e, packet.sessionId);
}
if (Services.profiler?.IsActive()) {
ChromeUtils.addProfilerMarker(
"CDP: Command",
{ startTime, category: "Remote-Protocol" },
`${method} (${id})`
);
}
}
}
/**
* Splits a CDP method into domain and command components.
*
* @param {string} method
* Name of the method to split, e.g. "Browser.getVersion".
*
* @returns {Object<string, string>}
* Object with the domain ("Browser") and command ("getVersion")
* as properties.
*/
export function splitMethod(method) {
const parts = method.split(".");
if (parts.length != 2 || !parts[0].length || !parts[1].length) {
throw new TypeError(`Invalid method format: '${method}'`);
}
return {
domain: parts[0],
command: parts[1],
};
}