tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 });