custom-formatters.js (16849B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 loader.lazyRequireGetter( 8 this, 9 "createValueGripForTarget", 10 "resource://devtools/server/actors/object/utils.js", 11 true 12 ); 13 14 loader.lazyRequireGetter( 15 this, 16 "ObjectUtils", 17 "resource://devtools/server/actors/object/utils.js" 18 ); 19 20 const _invalidCustomFormatterHooks = new WeakSet(); 21 function addInvalidCustomFormatterHooks(hook) { 22 if (!hook) { 23 return; 24 } 25 26 try { 27 _invalidCustomFormatterHooks.add(hook); 28 } catch (e) { 29 console.error("Couldn't add hook to the WeakSet", hook); 30 } 31 } 32 33 // Custom exception used between customFormatterHeader and processFormatterForHeader 34 class FormatterError extends Error { 35 constructor(message, script) { 36 super(message); 37 this.script = script; 38 } 39 } 40 41 /** 42 * Handle a protocol request to get the custom formatter header for an object. 43 * This is typically returned into ObjectActor's form if custom formatters are enabled. 44 * 45 * @param {ObjectActor} objectActor 46 * 47 * @returns {object} Data related to the custom formatter header: 48 * - {boolean} useCustomFormatter, indicating if a custom formatter is used. 49 * - {Array} header JsonML of the output header. 50 * - {boolean} hasBody True in case the custom formatter has a body. 51 * - {Object} formatter The devtoolsFormatters item that was being used to format 52 * the object. 53 */ 54 function customFormatterHeader(objectActor) { 55 const { rawObj } = objectActor; 56 const globalWrapper = Cu.getGlobalForObject(rawObj); 57 const global = globalWrapper?.wrappedJSObject; 58 59 // We expect a `devtoolsFormatters` global attribute and it to be an array 60 if (!global || !Array.isArray(global.devtoolsFormatters)) { 61 return null; 62 } 63 64 const customFormatterTooDeep = 65 (objectActor.hooks.customFormatterObjectTagDepth || 0) > 20; 66 if (customFormatterTooDeep) { 67 logCustomFormatterError( 68 globalWrapper, 69 `Too deep hierarchy of inlined custom previews` 70 ); 71 return null; 72 } 73 74 const { targetActor } = objectActor.threadActor; 75 76 const { 77 customFormatterConfigDbgObj: configDbgObj, 78 customFormatterObjectTagDepth, 79 } = objectActor.hooks; 80 81 const valueDbgObj = objectActor.obj; 82 83 for (const [ 84 customFormatterIndex, 85 formatter, 86 ] of global.devtoolsFormatters.entries()) { 87 // If the message for the erroneous formatter already got logged, 88 // skip logging it again. 89 if (_invalidCustomFormatterHooks.has(formatter)) { 90 continue; 91 } 92 93 // TODO: Any issues regarding the implementation will be covered in https://bugzil.la/1776611. 94 try { 95 const rv = processFormatterForHeader({ 96 configDbgObj, 97 customFormatterObjectTagDepth, 98 formatter, 99 targetActor, 100 valueDbgObj, 101 }); 102 // Return the first valid formatter value 103 if (rv) { 104 return rv; 105 } 106 } catch (e) { 107 logCustomFormatterError( 108 globalWrapper, 109 e instanceof FormatterError 110 ? `devtoolsFormatters[${customFormatterIndex}].${e.message}` 111 : `devtoolsFormatters[${customFormatterIndex}] couldn't be run: ${e.message}`, 112 // If the exception is FormatterError, this comes with a script attribute 113 e.script 114 ); 115 addInvalidCustomFormatterHooks(formatter); 116 } 117 } 118 119 return null; 120 } 121 exports.customFormatterHeader = customFormatterHeader; 122 123 /** 124 * Handle one precise custom formatter. 125 * i.e. one element of the window.customFormatters Array. 126 * 127 * @param {object} options 128 * @param {Debugger.Object} options.configDbgObj 129 * The Debugger.Object of the config object. 130 * @param {number} options.customFormatterObjectTagDepth 131 * See buildJsonMlFromCustomFormatterHookResult JSDoc. 132 * @param {object} options.formatter 133 * The raw formatter object (coming from "customFormatter" array). 134 * @param {BrowsingContextTargetActor} options.targetActor 135 * See buildJsonMlFromCustomFormatterHookResult JSDoc. 136 * @param {Debugger.Object} options.valueDbgObj 137 * The Debugger.Object of rawObj. 138 * 139 * @returns {object} See customFormatterHeader jsdoc, it returns the same object. 140 */ 141 // eslint-disable-next-line complexity 142 function processFormatterForHeader({ 143 configDbgObj, 144 customFormatterObjectTagDepth, 145 formatter, 146 targetActor, 147 valueDbgObj, 148 }) { 149 const headerType = typeof formatter?.header; 150 if (headerType !== "function") { 151 throw new FormatterError(`header should be a function, got ${headerType}`); 152 } 153 154 // Call the formatter's header attribute, which should be a function. 155 const formatterHeaderDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded( 156 valueDbgObj, 157 formatter.header 158 ); 159 const header = formatterHeaderDbgValue.call( 160 formatterHeaderDbgValue.boundThis, 161 valueDbgObj, 162 configDbgObj 163 ); 164 165 // If the header returns null, the custom formatter isn't used for that object 166 if (header?.return === null) { 167 return null; 168 } 169 170 // The header has to be an Array, all other cases are errors 171 if (header?.return?.class !== "Array") { 172 let errorMsg = ""; 173 if (header == null) { 174 errorMsg = `header was not run because it has side effects`; 175 } else if ("return" in header) { 176 let type = typeof header.return; 177 if (type === "object") { 178 type = header.return?.class; 179 } 180 errorMsg = `header should return an array, got ${type}`; 181 } else if ("throw" in header) { 182 errorMsg = `header threw: ${header.throw.getProperty("message")?.return}`; 183 } 184 185 throw new FormatterError(errorMsg, formatterHeaderDbgValue?.script); 186 } 187 188 const rawHeader = header.return.unsafeDereference(); 189 if (rawHeader.length === 0) { 190 throw new FormatterError( 191 `header returned an empty array`, 192 formatterHeaderDbgValue?.script 193 ); 194 } 195 196 const sanitizedHeader = buildJsonMlFromCustomFormatterHookResult( 197 header.return, 198 customFormatterObjectTagDepth, 199 targetActor 200 ); 201 202 let hasBody = false; 203 const hasBodyType = typeof formatter?.hasBody; 204 if (hasBodyType === "function") { 205 const formatterHasBodyDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded( 206 valueDbgObj, 207 formatter.hasBody 208 ); 209 hasBody = formatterHasBodyDbgValue.call( 210 formatterHasBodyDbgValue.boundThis, 211 valueDbgObj, 212 configDbgObj 213 ); 214 215 if (hasBody == null) { 216 throw new FormatterError( 217 `hasBody was not run because it has side effects`, 218 formatterHasBodyDbgValue?.script 219 ); 220 } else if ("throw" in hasBody) { 221 throw new FormatterError( 222 `hasBody threw: ${hasBody.throw.getProperty("message")?.return}`, 223 formatterHasBodyDbgValue?.script 224 ); 225 } 226 } else if (hasBodyType !== "undefined") { 227 throw new FormatterError( 228 `hasBody should be a function, got ${hasBodyType}` 229 ); 230 } 231 232 return { 233 useCustomFormatter: true, 234 header: sanitizedHeader, 235 hasBody: !!hasBody?.return, 236 formatter, 237 }; 238 } 239 240 /** 241 * Handle a protocol request to get the custom formatter body for an object 242 * 243 * @param {ObjectActor} objectActor 244 * @param {object} formatter: The global.devtoolsFormatters entry that was used in customFormatterHeader 245 * for this object. 246 * 247 * @returns {object} Data related to the custom formatter body: 248 * - {*} customFormatterBody Data of the custom formatter body. 249 */ 250 async function customFormatterBody(objectActor, formatter) { 251 const { rawObj } = objectActor; 252 const globalWrapper = Cu.getGlobalForObject(rawObj); 253 const global = globalWrapper?.wrappedJSObject; 254 255 const customFormatterIndex = global.devtoolsFormatters.indexOf(formatter); 256 257 const { targetActor } = objectActor.threadActor; 258 try { 259 const { customFormatterConfigDbgObj, customFormatterObjectTagDepth } = 260 objectActor.hooks; 261 262 if (_invalidCustomFormatterHooks.has(formatter)) { 263 return { 264 customFormatterBody: null, 265 }; 266 } 267 268 const bodyType = typeof formatter.body; 269 if (bodyType !== "function") { 270 logCustomFormatterError( 271 globalWrapper, 272 `devtoolsFormatters[${customFormatterIndex}].body should be a function, got ${bodyType}` 273 ); 274 addInvalidCustomFormatterHooks(formatter); 275 return { 276 customFormatterBody: null, 277 }; 278 } 279 280 const formatterBodyDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded( 281 objectActor.obj, 282 formatter.body 283 ); 284 const body = formatterBodyDbgValue.call( 285 formatterBodyDbgValue.boundThis, 286 objectActor.obj, 287 customFormatterConfigDbgObj 288 ); 289 if (body?.return?.class === "Array") { 290 const rawBody = body.return.unsafeDereference(); 291 if (rawBody.length === 0) { 292 logCustomFormatterError( 293 globalWrapper, 294 `devtoolsFormatters[${customFormatterIndex}].body returned an empty array`, 295 formatterBodyDbgValue?.script 296 ); 297 addInvalidCustomFormatterHooks(formatter); 298 return { 299 customFormatterBody: null, 300 }; 301 } 302 303 const customFormatterBodyJsonMl = 304 buildJsonMlFromCustomFormatterHookResult( 305 body.return, 306 customFormatterObjectTagDepth, 307 targetActor 308 ); 309 310 return { 311 customFormatterBody: customFormatterBodyJsonMl, 312 }; 313 } 314 315 let errorMsg = ""; 316 if (body == null) { 317 errorMsg = `devtoolsFormatters[${customFormatterIndex}].body was not run because it has side effects`; 318 } else if ("return" in body) { 319 let type = body.return === null ? "null" : typeof body.return; 320 if (type === "object") { 321 type = body.return?.class; 322 } 323 errorMsg = `devtoolsFormatters[${customFormatterIndex}].body should return an array, got ${type}`; 324 } else if ("throw" in body) { 325 errorMsg = `devtoolsFormatters[${customFormatterIndex}].body threw: ${ 326 body.throw.getProperty("message")?.return 327 }`; 328 } 329 330 logCustomFormatterError( 331 globalWrapper, 332 errorMsg, 333 formatterBodyDbgValue?.script 334 ); 335 addInvalidCustomFormatterHooks(formatter); 336 } catch (e) { 337 logCustomFormatterError( 338 globalWrapper, 339 `Custom formatter with index ${customFormatterIndex} couldn't be run: ${e.message}` 340 ); 341 } 342 343 return {}; 344 } 345 exports.customFormatterBody = customFormatterBody; 346 347 /** 348 * Log an error caused by a fault in a custom formatter to the web console. 349 * 350 * @param {Window} window The related global where we should log this message. 351 * This should be the xray wrapper in order to expose windowGlobalChild. 352 * The unwrapped, unpriviledged won't expose this attribute. 353 * @param {string} errorMsg Message to log to the console. 354 * @param {DebuggerObject} [script] The script causing the error. 355 */ 356 function logCustomFormatterError(window, errorMsg, script) { 357 const scriptErrorClass = Cc["@mozilla.org/scripterror;1"]; 358 const scriptError = scriptErrorClass.createInstance(Ci.nsIScriptError); 359 const { url, startLine, startColumn } = script ?? {}; 360 361 scriptError.initWithWindowID( 362 `Custom formatter failed: ${errorMsg}`, 363 url, 364 startLine, 365 startColumn, 366 Ci.nsIScriptError.errorFlag, 367 "devtoolsFormatter", 368 window.windowGlobalChild.innerWindowId 369 ); 370 Services.console.logMessage(scriptError); 371 } 372 373 /** 374 * Return a ready to use JsonMl object, safe to be sent to the client. 375 * This will replace JsonMl items with object reference, e.g `[ "object", { config: ..., object: ... } ]` 376 * with objectActor grip or "regular" JsonMl items (e.g. `["span", {style: "color: red"}, "this is", "an object"]`) 377 * if the referenced object gets custom formatted as well. 378 * 379 * @param {DebuggerObject} jsonMlDbgObj: The debugger object representing a jsonMl object returned 380 * by a custom formatter hook. 381 * @param {number} customFormatterObjectTagDepth: See `processObjectTag`. 382 * @param {BrowsingContextTargetActor} targetActor: The actor that will be managing any 383 * created ObjectActor. 384 * @returns {Array|null} Returns null if the passed object is a not DebuggerObject representing an Array 385 */ 386 function buildJsonMlFromCustomFormatterHookResult( 387 jsonMlDbgObj, 388 customFormatterObjectTagDepth, 389 targetActor 390 ) { 391 const tagName = jsonMlDbgObj.getProperty(0)?.return; 392 if (typeof tagName !== "string") { 393 const tagNameType = 394 tagName?.class || (tagName === null ? "null" : typeof tagName); 395 throw new Error(`tagName should be a string, got ${tagNameType}`); 396 } 397 398 // Fetch the other items of the jsonMl 399 const rest = []; 400 const dbgObjLength = jsonMlDbgObj.getProperty("length")?.return || 0; 401 for (let i = 1; i < dbgObjLength; i++) { 402 rest.push(jsonMlDbgObj.getProperty(i)?.return); 403 } 404 405 // The second item of the array can either be an object holding the attributes 406 // for the element or the first child element. 407 const attributesDbgObj = 408 rest[0] && rest[0].class === "Object" ? rest[0] : null; 409 const childrenDbgObj = attributesDbgObj ? rest.slice(1) : rest; 410 411 // If the tagName is "object", we need to replace the entry with the grip representing 412 // this object (that may or may not be custom formatted). 413 if (tagName == "object") { 414 if (!attributesDbgObj) { 415 throw new Error(`"object" tag should have attributes`); 416 } 417 418 // TODO: We could emit a warning if `childrenDbgObj` isn't empty as we're going to 419 // ignore them here. 420 return processObjectTag( 421 attributesDbgObj, 422 customFormatterObjectTagDepth, 423 targetActor 424 ); 425 } 426 427 const jsonMl = [tagName, {}]; 428 if (attributesDbgObj) { 429 // For non "object" tags, we only care about the style property 430 jsonMl[1].style = attributesDbgObj.getProperty("style")?.return; 431 } 432 433 // Handle children, which could be simple primitives or JsonML objects 434 for (const childDbgObj of childrenDbgObj) { 435 const childDbgObjType = typeof childDbgObj; 436 if (childDbgObj?.class === "Array") { 437 // `childDbgObj` probably holds a JsonMl item, sanitize it. 438 jsonMl.push( 439 buildJsonMlFromCustomFormatterHookResult( 440 childDbgObj, 441 customFormatterObjectTagDepth, 442 targetActor 443 ) 444 ); 445 } else if (childDbgObjType == "object" && childDbgObj !== null) { 446 // If we don't have an array, match Chrome implementation. 447 jsonMl.push("[object Object]"); 448 } else { 449 // Here `childDbgObj` is a primitive. Create a grip so we can handle all the types 450 // we can stringify easily (e.g. `undefined`, `bigint`, …). 451 const grip = createValueGripForTarget(targetActor, childDbgObj); 452 if (grip !== null) { 453 jsonMl.push(grip); 454 } 455 } 456 } 457 return jsonMl; 458 } 459 460 /** 461 * Return a ready to use JsonMl object, safe to be sent to the client. 462 * This will replace JsonMl items with object reference, e.g `[ "object", { config: ..., object: ... } ]` 463 * with objectActor grip or "regular" JsonMl items (e.g. `["span", {style: "color: red"}, "this is", "an object"]`) 464 * if the referenced object gets custom formatted as well. 465 * 466 * @param {DebuggerObject} attributesDbgObj: The debugger object representing the "attributes" 467 * of a jsonMl item (e.g. the second item in the array). 468 * @param {number} customFormatterObjectTagDepth: As "object" tag can reference custom 469 * formatted data, we track the number of time we go through this function 470 * from the "root" object so we don't have an infinite loop. 471 * @param {BrowsingContextTargetActor} targetActor: The actor that will be managin any 472 * created ObjectActor. 473 * @returns {object} Returns a grip representing the underlying object 474 */ 475 function processObjectTag( 476 attributesDbgObj, 477 customFormatterObjectTagDepth, 478 targetActor 479 ) { 480 const objectDbgObj = attributesDbgObj.getProperty("object")?.return; 481 if (typeof objectDbgObj == "undefined") { 482 throw new Error( 483 `attribute of "object" tag should have an "object" property` 484 ); 485 } 486 487 // We need to replace the "object" tag with the actual `attribute.object` object, 488 // which might be also custom formatted. 489 // We create the grip so the custom formatter hooks can be called on this object, or 490 // we'd get an object grip that we can consume to display an ObjectInspector on the client. 491 const configRv = attributesDbgObj.getProperty("config"); 492 const grip = createValueGripForTarget(targetActor, objectDbgObj, 0, { 493 // Store the config so we can pass it when calling custom formatter hooks for this object. 494 customFormatterConfigDbgObj: configRv?.return, 495 customFormatterObjectTagDepth: (customFormatterObjectTagDepth || 0) + 1, 496 }); 497 498 return grip; 499 }