json-viewer.mjs (12413B)
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 /* eslint no-shadow: ["error", { "allow": ["dispatchEvent"] }] */ 6 7 import ReactDOM from "resource://devtools/client/shared/vendor/react-dom.mjs"; 8 import { createFactories } from "resource://devtools/client/shared/react-utils.mjs"; 9 10 import MainTabbedAreaClass from "resource://devtools/client/jsonview/components/MainTabbedArea.mjs"; 11 import TreeViewClass from "resource://devtools/client/shared/components/tree/TreeView.mjs"; 12 import { ObjectProvider } from "resource://devtools/client/shared/components/tree/ObjectProvider.mjs"; 13 import { JSON_NUMBER } from "resource://devtools/client/shared/components/reps/reps/constants.mjs"; 14 import { parseJsonLossless } from "resource://devtools/client/shared/components/reps/reps/rep-utils.mjs"; 15 import { createSizeProfile } from "resource://devtools/client/jsonview/json-size-profiler.mjs"; 16 17 const { MainTabbedArea } = createFactories(MainTabbedAreaClass); 18 19 // Send readyState change notification event to the window. It's useful for tests. 20 JSONView.readyState = "loading"; 21 window.dispatchEvent(new CustomEvent("AppReadyStateChange")); 22 23 const AUTO_EXPAND_MAX_SIZE = 100 * 1024; 24 const AUTO_EXPAND_MAX_LEVEL = 7; 25 const EXPAND_ALL_MAX_NODES = 100000; 26 const TABS = { 27 JSON: 0, 28 RAW_DATA: 1, 29 HEADERS: 2, 30 }; 31 32 let prettyURL; 33 let theApp; 34 35 // Application state object. 36 const input = { 37 jsonText: JSONView.json, 38 jsonPretty: null, 39 headers: JSONView.headers, 40 activeTab: 0, 41 prettified: false, 42 expandedNodes: new Set(), 43 }; 44 45 /** 46 * Recursively walk the tree and expand all nodes including buckets. 47 * Similar to TreeViewClass.getExpandedNodes but includes buckets. 48 */ 49 function expandAllNodes(data, { maxNodes = Infinity } = {}) { 50 const expandedNodes = new Set(); 51 52 function walkTree(object, path = "") { 53 const children = ObjectProvider.getChildren(object, { 54 bucketLargeArrays: true, 55 }); 56 57 // Check if adding these children would exceed the limit 58 if (expandedNodes.size + children.length > maxNodes) { 59 // Avoid having children half expanded 60 return; 61 } 62 63 for (const child of children) { 64 const key = ObjectProvider.getKey(child); 65 const childPath = TreeViewClass.subPath(path, key); 66 67 // Expand this node 68 expandedNodes.add(childPath); 69 70 // Recursively walk children 71 if (ObjectProvider.hasChildren(child)) { 72 walkTree(child, childPath); 73 } 74 } 75 } 76 77 // Start walking from the root if it's not a primitive 78 if ( 79 data && 80 typeof data === "object" && 81 !(data instanceof Error) && 82 data.type !== JSON_NUMBER 83 ) { 84 walkTree(data); 85 } 86 87 return expandedNodes; 88 } 89 90 /** 91 * Recursively walk the tree and expand buckets that contain matches. 92 */ 93 function expandBucketsWithMatches(data, searchFilter) { 94 const expandedNodes = new Set(input.expandedNodes); 95 96 function walkTree(object, path = "") { 97 const children = ObjectProvider.getChildren(object, { 98 bucketLargeArrays: true, 99 }); 100 101 for (const child of children) { 102 const key = ObjectProvider.getKey(child); 103 const childPath = TreeViewClass.subPath(path, key); 104 105 // Check if this is a bucket 106 if (ObjectProvider.getType(child) === "bucket") { 107 // Check if any children in the bucket match the filter 108 const { object: array, startIndex, endIndex } = child; 109 let hasMatch = false; 110 111 for (let i = startIndex; i <= endIndex; i++) { 112 const childJson = JSON.stringify(array[i]); 113 if (childJson.toLowerCase().includes(searchFilter)) { 114 hasMatch = true; 115 break; 116 } 117 } 118 119 if (hasMatch) { 120 expandedNodes.add(childPath); 121 } 122 } else if (ObjectProvider.hasChildren(child)) { 123 // Recursively walk non-bucket nodes 124 walkTree(child, childPath); 125 } 126 } 127 } 128 129 // Start walking from the root if it's not a primitive 130 if ( 131 data && 132 typeof data === "object" && 133 !(data instanceof Error) && 134 data.type !== JSON_NUMBER 135 ) { 136 walkTree(data); 137 } 138 139 return expandedNodes; 140 } 141 142 /** 143 * Application actions/commands. This list implements all commands 144 * available for the JSON viewer. 145 */ 146 input.actions = { 147 onCopyJson() { 148 const text = input.prettified ? input.jsonPretty : input.jsonText; 149 copyString(text.textContent); 150 }, 151 152 onSaveJson() { 153 if (input.prettified && !prettyURL) { 154 prettyURL = URL.createObjectURL( 155 new window.Blob([input.jsonPretty.textContent]) 156 ); 157 } 158 dispatchEvent("save", input.prettified ? prettyURL : null); 159 }, 160 161 onCopyHeaders() { 162 let value = ""; 163 const isWinNT = document.documentElement.getAttribute("platform") === "win"; 164 const eol = isWinNT ? "\r\n" : "\n"; 165 166 const responseHeaders = input.headers.response; 167 for (let i = 0; i < responseHeaders.length; i++) { 168 const header = responseHeaders[i]; 169 value += header.name + ": " + header.value + eol; 170 } 171 172 value += eol; 173 174 const requestHeaders = input.headers.request; 175 for (let i = 0; i < requestHeaders.length; i++) { 176 const header = requestHeaders[i]; 177 value += header.name + ": " + header.value + eol; 178 } 179 180 copyString(value); 181 }, 182 183 onSearch(value) { 184 const expandedNodes = value 185 ? expandBucketsWithMatches(input.json, value.toLowerCase()) 186 : input.expandedNodes; 187 theApp.setState({ searchFilter: value, expandedNodes }); 188 }, 189 190 onPrettify() { 191 if (input.json instanceof Error) { 192 // Cannot prettify invalid JSON 193 return; 194 } 195 if (input.prettified) { 196 theApp.setState({ jsonText: input.jsonText }); 197 } else { 198 if (!input.jsonPretty) { 199 input.jsonPretty = new Text( 200 JSON.stringify( 201 input.json, 202 (key, value) => { 203 if (value?.type === JSON_NUMBER) { 204 return JSON.rawJSON(value.source); 205 } 206 207 // By default, -0 will be stringified as `0`, so we need to handle it 208 if (Object.is(value, -0)) { 209 return JSON.rawJSON("-0"); 210 } 211 212 return value; 213 }, 214 " " 215 ) 216 ); 217 } 218 theApp.setState({ jsonText: input.jsonPretty }); 219 } 220 221 input.prettified = !input.prettified; 222 }, 223 224 onCollapse() { 225 input.expandedNodes.clear(); 226 theApp.forceUpdate(); 227 }, 228 229 onExpand() { 230 input.expandedNodes = expandAllNodes(input.json, { 231 maxNodes: EXPAND_ALL_MAX_NODES, 232 }); 233 theApp.setState({ expandedNodes: input.expandedNodes }); 234 }, 235 236 async onProfileSize() { 237 // Get the raw JSON string 238 const jsonString = input.jsonText.textContent; 239 240 // Get profiler URL from preferences and open window immediately 241 // to avoid popup blocker (profile creation may take several seconds) 242 const origin = JSONView.profilerUrl; 243 const profilerURL = origin + "/from-post-message/"; 244 const profilerWindow = window.open(profilerURL, "_blank"); 245 246 if (!profilerWindow) { 247 console.error("Failed to open profiler window"); 248 return; 249 } 250 251 // Extract filename from URL 252 let filename; 253 try { 254 const pathname = window.location.pathname; 255 const lastSlash = pathname.lastIndexOf("/"); 256 if (lastSlash !== -1 && lastSlash < pathname.length - 1) { 257 filename = decodeURIComponent(pathname.substring(lastSlash + 1)); 258 } 259 } catch (e) { 260 // Invalid URL encoding, leave filename undefined 261 } 262 263 const profile = createSizeProfile(jsonString, filename); 264 265 // Wait for profiler to be ready and send the profile 266 let isReady = false; 267 const messageHandler = function (event) { 268 if (event.origin !== origin) { 269 return; 270 } 271 if (event.data && event.data.name === "ready:response") { 272 window.removeEventListener("message", messageHandler); 273 isReady = true; 274 } 275 }; 276 window.addEventListener("message", messageHandler); 277 278 // Poll until the profiler window is ready. We need to poll because the 279 // postMessage will not be received if we send it before the profiler 280 // tab has finished loading. 281 while (!isReady) { 282 await new Promise(resolve => setTimeout(resolve, 100)); 283 profilerWindow.postMessage({ name: "ready:request" }, origin); 284 } 285 286 profilerWindow.postMessage( 287 { 288 name: "inject-profile", 289 profile, 290 }, 291 origin 292 ); 293 }, 294 }; 295 296 /** 297 * Helper for copying a string to the clipboard. 298 * 299 * @param {string} string The text to be copied. 300 */ 301 function copyString(string) { 302 document.addEventListener( 303 "copy", 304 event => { 305 event.clipboardData.setData("text/plain", string); 306 event.preventDefault(); 307 }, 308 { once: true } 309 ); 310 311 document.execCommand("copy", false, null); 312 } 313 314 /** 315 * Helper for dispatching an event. It's handled in chrome scope. 316 * 317 * @param {string} type Event detail type 318 * @param {object} value Event detail value 319 */ 320 function dispatchEvent(type, value) { 321 const data = { 322 detail: { 323 type, 324 value, 325 }, 326 }; 327 328 const contentMessageEvent = new CustomEvent("contentMessage", data); 329 window.dispatchEvent(contentMessageEvent); 330 } 331 332 /** 333 * Render the main application component. It's the main tab bar displayed 334 * at the top of the window. This component also represents ReacJS root. 335 */ 336 const content = document.getElementById("content"); 337 const promise = (async function parseJSON() { 338 if (document.readyState == "loading") { 339 // If the JSON has not been loaded yet, render the Raw Data tab first. 340 input.json = {}; 341 input.activeTab = TABS.RAW_DATA; 342 return new Promise(resolve => { 343 document.addEventListener("DOMContentLoaded", resolve, { once: true }); 344 }) 345 .then(parseJSON) 346 .then(async () => { 347 // Now update the state and switch to the JSON tab. 348 await appIsReady; 349 theApp.setState({ 350 activeTab: TABS.JSON, 351 json: input.json, 352 expandedNodes: input.expandedNodes, 353 }); 354 }); 355 } 356 357 // If the JSON has been loaded, parse it immediately before loading the app. 358 const jsonString = input.jsonText.textContent; 359 try { 360 input.json = parseJsonLossless(jsonString); 361 362 // Expose a clean public API for accessing JSON data from the console 363 // This is not tied to internal implementation details 364 window.$json = { 365 // The parsed JSON data 366 get data() { 367 return input.json; 368 }, 369 // The original JSON text 370 get text() { 371 return jsonString; 372 }, 373 // HTTP headers 374 get headers() { 375 return JSONView.headers; 376 }, 377 }; 378 379 // Log a welcome message to the console 380 const intro = "font-size: 130%;"; 381 const bold = "font-family: monospace; font-weight: bold;"; 382 const reset = ""; 383 console.log( 384 "%cData available from the console:%c\n\n" + 385 "%c$json.data%c - The parsed JSON object\n" + 386 "%c$json.text%c - The original JSON text\n" + 387 "%c$json.headers%c - HTTP request and response headers\n\n" + 388 "The JSON Viewer is documented here:\n" + 389 "https://firefox-source-docs.mozilla.org/devtools-user/json_viewer/", 390 intro, 391 reset, 392 bold, 393 reset, 394 bold, 395 reset, 396 bold, 397 reset 398 ); 399 } catch (err) { 400 input.json = err; 401 // Display the raw data tab for invalid json 402 input.activeTab = TABS.RAW_DATA; 403 } 404 405 // Expand the document by default if its size isn't bigger than 100KB. 406 if ( 407 !(input.json instanceof Error) && 408 jsonString.length <= AUTO_EXPAND_MAX_SIZE 409 ) { 410 input.expandedNodes = TreeViewClass.getExpandedNodes(input.json, { 411 maxLevel: AUTO_EXPAND_MAX_LEVEL, 412 }); 413 } 414 return undefined; 415 })(); 416 417 const appIsReady = new Promise(resolve => { 418 ReactDOM.render(MainTabbedArea(input), content, function () { 419 theApp = this; 420 resolve(); 421 422 // Send readyState change notification event to the window. Can be useful for 423 // tests as well as extensions. 424 JSONView.readyState = "interactive"; 425 window.dispatchEvent(new CustomEvent("AppReadyStateChange")); 426 427 promise.then(() => { 428 // Another readyState change notification event. 429 JSONView.readyState = "complete"; 430 window.dispatchEvent(new CustomEvent("AppReadyStateChange")); 431 }); 432 }); 433 });