Source code

Revision control

Copy as Markdown

Other Tools

/* 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/. */
"use strict";
loader.lazyRequireGetter(
this,
"createValueGripForTarget",
true
);
loader.lazyRequireGetter(
this,
"ObjectUtils",
);
const _invalidCustomFormatterHooks = new WeakSet();
function addInvalidCustomFormatterHooks(hook) {
if (!hook) {
return;
}
try {
_invalidCustomFormatterHooks.add(hook);
} catch (e) {
console.error("Couldn't add hook to the WeakSet", hook);
}
}
// Custom exception used between customFormatterHeader and processFormatterForHeader
class FormatterError extends Error {
constructor(message, script) {
super(message);
this.script = script;
}
}
/**
* Handle a protocol request to get the custom formatter header for an object.
* This is typically returned into ObjectActor's form if custom formatters are enabled.
*
* @param {ObjectActor} objectActor
*
* @returns {Object} Data related to the custom formatter header:
* - {boolean} useCustomFormatter, indicating if a custom formatter is used.
* - {Array} header JsonML of the output header.
* - {boolean} hasBody True in case the custom formatter has a body.
* - {Object} formatter The devtoolsFormatters item that was being used to format
* the object.
*/
function customFormatterHeader(objectActor) {
const rawValue = objectActor.rawValue();
const globalWrapper = Cu.getGlobalForObject(rawValue);
const global = globalWrapper?.wrappedJSObject;
// We expect a `devtoolsFormatters` global attribute and it to be an array
if (!global || !Array.isArray(global.devtoolsFormatters)) {
return null;
}
const customFormatterTooDeep =
(objectActor.hooks.customFormatterObjectTagDepth || 0) > 20;
if (customFormatterTooDeep) {
logCustomFormatterError(
globalWrapper,
`Too deep hierarchy of inlined custom previews`
);
return null;
}
const { targetActor } = objectActor.thread;
const {
customFormatterConfigDbgObj: configDbgObj,
customFormatterObjectTagDepth,
} = objectActor.hooks;
const valueDbgObj = objectActor.obj;
for (const [
customFormatterIndex,
formatter,
] of global.devtoolsFormatters.entries()) {
// If the message for the erroneous formatter already got logged,
// skip logging it again.
if (_invalidCustomFormatterHooks.has(formatter)) {
continue;
}
// TODO: Any issues regarding the implementation will be covered in https://bugzil.la/1776611.
try {
const rv = processFormatterForHeader({
configDbgObj,
customFormatterObjectTagDepth,
formatter,
targetActor,
valueDbgObj,
});
// Return the first valid formatter value
if (rv) {
return rv;
}
} catch (e) {
logCustomFormatterError(
globalWrapper,
e instanceof FormatterError
? `devtoolsFormatters[${customFormatterIndex}].${e.message}`
: `devtoolsFormatters[${customFormatterIndex}] couldn't be run: ${e.message}`,
// If the exception is FormatterError, this comes with a script attribute
e.script
);
addInvalidCustomFormatterHooks(formatter);
}
}
return null;
}
exports.customFormatterHeader = customFormatterHeader;
/**
* Handle one precise custom formatter.
* i.e. one element of the window.customFormatters Array.
*
* @param {Object} options
* @param {Debugger.Object} options.configDbgObj
* The Debugger.Object of the config object.
* @param {Number} options.customFormatterObjectTagDepth
* See buildJsonMlFromCustomFormatterHookResult JSDoc.
* @param {Object} options.formatter
* The raw formatter object (coming from "customFormatter" array).
* @param {BrowsingContextTargetActor} options.targetActor
* See buildJsonMlFromCustomFormatterHookResult JSDoc.
* @param {Debugger.Object} options.valueDbgObj
* The Debugger.Object of rawValue.
*
* @returns {Object} See customFormatterHeader jsdoc, it returns the same object.
*/
function processFormatterForHeader({
configDbgObj,
customFormatterObjectTagDepth,
formatter,
targetActor,
valueDbgObj,
}) {
const headerType = typeof formatter?.header;
if (headerType !== "function") {
throw new FormatterError(`header should be a function, got ${headerType}`);
}
// Call the formatter's header attribute, which should be a function.
const formatterHeaderDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded(
valueDbgObj,
formatter.header
);
const header = formatterHeaderDbgValue.call(
formatterHeaderDbgValue.boundThis,
valueDbgObj,
configDbgObj
);
// If the header returns null, the custom formatter isn't used for that object
if (header?.return === null) {
return null;
}
// The header has to be an Array, all other cases are errors
if (header?.return?.class !== "Array") {
let errorMsg = "";
if (header == null) {
errorMsg = `header was not run because it has side effects`;
} else if ("return" in header) {
let type = typeof header.return;
if (type === "object") {
type = header.return?.class;
}
errorMsg = `header should return an array, got ${type}`;
} else if ("throw" in header) {
errorMsg = `header threw: ${header.throw.getProperty("message")?.return}`;
}
throw new FormatterError(errorMsg, formatterHeaderDbgValue?.script);
}
const rawHeader = header.return.unsafeDereference();
if (rawHeader.length === 0) {
throw new FormatterError(
`header returned an empty array`,
formatterHeaderDbgValue?.script
);
}
const sanitizedHeader = buildJsonMlFromCustomFormatterHookResult(
header.return,
customFormatterObjectTagDepth,
targetActor
);
let hasBody = false;
const hasBodyType = typeof formatter?.hasBody;
if (hasBodyType === "function") {
const formatterHasBodyDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded(
valueDbgObj,
formatter.hasBody
);
hasBody = formatterHasBodyDbgValue.call(
formatterHasBodyDbgValue.boundThis,
valueDbgObj,
configDbgObj
);
if (hasBody == null) {
throw new FormatterError(
`hasBody was not run because it has side effects`,
formatterHasBodyDbgValue?.script
);
} else if ("throw" in hasBody) {
throw new FormatterError(
`hasBody threw: ${hasBody.throw.getProperty("message")?.return}`,
formatterHasBodyDbgValue?.script
);
}
} else if (hasBodyType !== "undefined") {
throw new FormatterError(
`hasBody should be a function, got ${hasBodyType}`
);
}
return {
useCustomFormatter: true,
header: sanitizedHeader,
hasBody: !!hasBody?.return,
formatter,
};
}
/**
* Handle a protocol request to get the custom formatter body for an object
*
* @param {ObjectActor} objectActor
* @param {Object} formatter: The global.devtoolsFormatters entry that was used in customFormatterHeader
* for this object.
*
* @returns {Object} Data related to the custom formatter body:
* - {*} customFormatterBody Data of the custom formatter body.
*/
async function customFormatterBody(objectActor, formatter) {
const rawValue = objectActor.rawValue();
const globalWrapper = Cu.getGlobalForObject(rawValue);
const global = globalWrapper?.wrappedJSObject;
const customFormatterIndex = global.devtoolsFormatters.indexOf(formatter);
const { targetActor } = objectActor.thread;
try {
const { customFormatterConfigDbgObj, customFormatterObjectTagDepth } =
objectActor.hooks;
if (_invalidCustomFormatterHooks.has(formatter)) {
return {
customFormatterBody: null,
};
}
const bodyType = typeof formatter.body;
if (bodyType !== "function") {
logCustomFormatterError(
globalWrapper,
`devtoolsFormatters[${customFormatterIndex}].body should be a function, got ${bodyType}`
);
addInvalidCustomFormatterHooks(formatter);
return {
customFormatterBody: null,
};
}
const formatterBodyDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded(
objectActor.obj,
formatter.body
);
const body = formatterBodyDbgValue.call(
formatterBodyDbgValue.boundThis,
objectActor.obj,
customFormatterConfigDbgObj
);
if (body?.return?.class === "Array") {
const rawBody = body.return.unsafeDereference();
if (rawBody.length === 0) {
logCustomFormatterError(
globalWrapper,
`devtoolsFormatters[${customFormatterIndex}].body returned an empty array`,
formatterBodyDbgValue?.script
);
addInvalidCustomFormatterHooks(formatter);
return {
customFormatterBody: null,
};
}
const customFormatterBodyJsonMl =
buildJsonMlFromCustomFormatterHookResult(
body.return,
customFormatterObjectTagDepth,
targetActor
);
return {
customFormatterBody: customFormatterBodyJsonMl,
};
}
let errorMsg = "";
if (body == null) {
errorMsg = `devtoolsFormatters[${customFormatterIndex}].body was not run because it has side effects`;
} else if ("return" in body) {
let type = body.return === null ? "null" : typeof body.return;
if (type === "object") {
type = body.return?.class;
}
errorMsg = `devtoolsFormatters[${customFormatterIndex}].body should return an array, got ${type}`;
} else if ("throw" in body) {
errorMsg = `devtoolsFormatters[${customFormatterIndex}].body threw: ${
body.throw.getProperty("message")?.return
}`;
}
logCustomFormatterError(
globalWrapper,
errorMsg,
formatterBodyDbgValue?.script
);
addInvalidCustomFormatterHooks(formatter);
} catch (e) {
logCustomFormatterError(
globalWrapper,
`Custom formatter with index ${customFormatterIndex} couldn't be run: ${e.message}`
);
}
return {};
}
exports.customFormatterBody = customFormatterBody;
/**
* Log an error caused by a fault in a custom formatter to the web console.
*
* @param {Window} window The related global where we should log this message.
* This should be the xray wrapper in order to expose windowGlobalChild.
* The unwrapped, unpriviledged won't expose this attribute.
* @param {string} errorMsg Message to log to the console.
* @param {DebuggerObject} [script] The script causing the error.
*/
function logCustomFormatterError(window, errorMsg, script) {
const scriptErrorClass = Cc["@mozilla.org/scripterror;1"];
const scriptError = scriptErrorClass.createInstance(Ci.nsIScriptError);
const { url, source, startLine, startColumn } = script ?? {};
scriptError.initWithWindowID(
`Custom formatter failed: ${errorMsg}`,
url,
source,
startLine,
startColumn,
Ci.nsIScriptError.errorFlag,
"devtoolsFormatter",
window.windowGlobalChild.innerWindowId
);
Services.console.logMessage(scriptError);
}
/**
* Return a ready to use JsonMl object, safe to be sent to the client.
* This will replace JsonMl items with object reference, e.g `[ "object", { config: ..., object: ... } ]`
* with objectActor grip or "regular" JsonMl items (e.g. `["span", {style: "color: red"}, "this is", "an object"]`)
* if the referenced object gets custom formatted as well.
*
* @param {DebuggerObject} jsonMlDbgObj: The debugger object representing a jsonMl object returned
* by a custom formatter hook.
* @param {Number} customFormatterObjectTagDepth: See `processObjectTag`.
* @param {BrowsingContextTargetActor} targetActor: The actor that will be managing any
* created ObjectActor.
* @returns {Array|null} Returns null if the passed object is a not DebuggerObject representing an Array
*/
function buildJsonMlFromCustomFormatterHookResult(
jsonMlDbgObj,
customFormatterObjectTagDepth,
targetActor
) {
const tagName = jsonMlDbgObj.getProperty(0)?.return;
if (typeof tagName !== "string") {
const tagNameType =
tagName?.class || (tagName === null ? "null" : typeof tagName);
throw new Error(`tagName should be a string, got ${tagNameType}`);
}
// Fetch the other items of the jsonMl
const rest = [];
const dbgObjLength = jsonMlDbgObj.getProperty("length")?.return || 0;
for (let i = 1; i < dbgObjLength; i++) {
rest.push(jsonMlDbgObj.getProperty(i)?.return);
}
// The second item of the array can either be an object holding the attributes
// for the element or the first child element.
const attributesDbgObj =
rest[0] && rest[0].class === "Object" ? rest[0] : null;
const childrenDbgObj = attributesDbgObj ? rest.slice(1) : rest;
// If the tagName is "object", we need to replace the entry with the grip representing
// this object (that may or may not be custom formatted).
if (tagName == "object") {
if (!attributesDbgObj) {
throw new Error(`"object" tag should have attributes`);
}
// TODO: We could emit a warning if `childrenDbgObj` isn't empty as we're going to
// ignore them here.
return processObjectTag(
attributesDbgObj,
customFormatterObjectTagDepth,
targetActor
);
}
const jsonMl = [tagName, {}];
if (attributesDbgObj) {
// For non "object" tags, we only care about the style property
jsonMl[1].style = attributesDbgObj.getProperty("style")?.return;
}
// Handle children, which could be simple primitives or JsonML objects
for (const childDbgObj of childrenDbgObj) {
const childDbgObjType = typeof childDbgObj;
if (childDbgObj?.class === "Array") {
// `childDbgObj` probably holds a JsonMl item, sanitize it.
jsonMl.push(
buildJsonMlFromCustomFormatterHookResult(
childDbgObj,
customFormatterObjectTagDepth,
targetActor
)
);
} else if (childDbgObjType == "object" && childDbgObj !== null) {
// If we don't have an array, match Chrome implementation.
jsonMl.push("[object Object]");
} else {
// Here `childDbgObj` is a primitive. Create a grip so we can handle all the types
// we can stringify easily (e.g. `undefined`, `bigint`, …).
const grip = createValueGripForTarget(targetActor, childDbgObj);
if (grip !== null) {
jsonMl.push(grip);
}
}
}
return jsonMl;
}
/**
* Return a ready to use JsonMl object, safe to be sent to the client.
* This will replace JsonMl items with object reference, e.g `[ "object", { config: ..., object: ... } ]`
* with objectActor grip or "regular" JsonMl items (e.g. `["span", {style: "color: red"}, "this is", "an object"]`)
* if the referenced object gets custom formatted as well.
*
* @param {DebuggerObject} attributesDbgObj: The debugger object representing the "attributes"
* of a jsonMl item (e.g. the second item in the array).
* @param {Number} customFormatterObjectTagDepth: As "object" tag can reference custom
* formatted data, we track the number of time we go through this function
* from the "root" object so we don't have an infinite loop.
* @param {BrowsingContextTargetActor} targetActor: The actor that will be managin any
* created ObjectActor.
* @returns {Object} Returns a grip representing the underlying object
*/
function processObjectTag(
attributesDbgObj,
customFormatterObjectTagDepth,
targetActor
) {
const objectDbgObj = attributesDbgObj.getProperty("object")?.return;
if (typeof objectDbgObj == "undefined") {
throw new Error(
`attribute of "object" tag should have an "object" property`
);
}
// We need to replace the "object" tag with the actual `attribute.object` object,
// which might be also custom formatted.
// We create the grip so the custom formatter hooks can be called on this object, or
// we'd get an object grip that we can consume to display an ObjectInspector on the client.
const configRv = attributesDbgObj.getProperty("config");
const grip = createValueGripForTarget(targetActor, objectDbgObj, 0, {
// Store the config so we can pass it when calling custom formatter hooks for this object.
customFormatterConfigDbgObj: configRv?.return,
customFormatterObjectTagDepth: (customFormatterObjectTagDepth || 0) + 1,
});
return grip;
}