498 lines
16 KiB
JavaScript
498 lines
16 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/. */
|
|
|
|
"use strict";
|
|
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"createValueGripForTarget",
|
|
"resource://devtools/server/actors/object/utils.js",
|
|
true
|
|
);
|
|
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"ObjectUtils",
|
|
"resource://devtools/server/actors/object/utils.js"
|
|
);
|
|
|
|
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 { rawObj } = objectActor;
|
|
const globalWrapper = Cu.getGlobalForObject(rawObj);
|
|
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.threadActor;
|
|
|
|
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 rawObj.
|
|
*
|
|
* @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 { rawObj } = objectActor;
|
|
const globalWrapper = Cu.getGlobalForObject(rawObj);
|
|
const global = globalWrapper?.wrappedJSObject;
|
|
|
|
const customFormatterIndex = global.devtoolsFormatters.indexOf(formatter);
|
|
|
|
const { targetActor } = objectActor.threadActor;
|
|
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, startLine, startColumn } = script ?? {};
|
|
|
|
scriptError.initWithWindowID(
|
|
`Custom formatter failed: ${errorMsg}`,
|
|
url,
|
|
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;
|
|
}
|