converter-child.js (12935B)
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 const lazy = {}; 8 ChromeUtils.defineESModuleGetters(lazy, { 9 NetUtil: "resource://gre/modules/NetUtil.sys.mjs", 10 }); 11 12 const { 13 getTheme, 14 addThemeObserver, 15 removeThemeObserver, 16 } = require("resource://devtools/client/shared/theme.js"); 17 18 const BinaryInput = Components.Constructor( 19 "@mozilla.org/binaryinputstream;1", 20 "nsIBinaryInputStream", 21 "setInputStream" 22 ); 23 const BufferStream = Components.Constructor( 24 "@mozilla.org/io/arraybuffer-input-stream;1", 25 "nsIArrayBufferInputStream", 26 "setData" 27 ); 28 29 const kCSP = "default-src 'none'; script-src resource:; img-src 'self';"; 30 31 // Localization 32 loader.lazyGetter(this, "jsonViewStrings", () => { 33 return Services.strings.createBundle( 34 "chrome://devtools/locale/jsonview.properties" 35 ); 36 }); 37 38 /** 39 * This object detects 'application/vnd.mozilla.json.view' content type 40 * and converts it into a JSON Viewer application that allows simple 41 * JSON inspection. 42 * 43 * Inspired by JSON View: https://github.com/bhollis/jsonview/ 44 */ 45 function Converter() {} 46 47 Converter.prototype = { 48 QueryInterface: ChromeUtils.generateQI([ 49 "nsIStreamConverter", 50 "nsIStreamListener", 51 "nsIRequestObserver", 52 ]), 53 54 get wrappedJSObject() { 55 return this; 56 }, 57 58 /** 59 * This component works as such: 60 * 1. asyncConvertData captures the listener 61 * 2. onStartRequest fires, initializes stuff, modifies the listener 62 * to match our output type 63 * 3. onDataAvailable decodes and inserts data into a text node 64 * 4. onStopRequest flushes data and spits back to the listener 65 * 5. convert does nothing, it's just the synchronous version 66 * of asyncConvertData 67 */ 68 convert(fromStream) { 69 return fromStream; 70 }, 71 72 asyncConvertData(fromType, toType, listener) { 73 this.listener = listener; 74 }, 75 getConvertedType(_fromType, channel) { 76 if (channel instanceof Ci.nsIMultiPartChannel) { 77 throw new Components.Exception( 78 "JSONViewer doesn't support multipart responses.", 79 Cr.NS_ERROR_FAILURE 80 ); 81 } 82 return "text/html"; 83 }, 84 85 onDataAvailable(request, inputStream, offset, count) { 86 // Decode and insert data. 87 const buffer = new ArrayBuffer(count); 88 new BinaryInput(inputStream).readArrayBuffer(count, buffer); 89 this.decodeAndInsertBuffer(buffer); 90 }, 91 92 onStartRequest(request) { 93 // Set the content type to HTML in order to parse the doctype, styles 94 // and scripts. The JSON will be manually inserted as text. 95 request.QueryInterface(Ci.nsIChannel); 96 request.contentType = "text/html"; 97 98 // Tweak the request's principal in order to allow the related HTML document 99 // used to display raw JSON to be able to load resource://devtools files 100 // from the jsonview document. 101 const uri = lazy.NetUtil.newURI("resource://devtools/client/jsonview/"); 102 const resourcePrincipal = 103 Services.scriptSecurityManager.createContentPrincipal( 104 uri, 105 request.loadInfo.originAttributes 106 ); 107 request.owner = resourcePrincipal; 108 109 const headers = getHttpHeaders(request); 110 111 // Enforce strict CSP: 112 try { 113 request.QueryInterface(Ci.nsIHttpChannel); 114 request.setResponseHeader("Content-Security-Policy", kCSP, false); 115 request.setResponseHeader( 116 "Content-Security-Policy-Report-Only", 117 "", 118 false 119 ); 120 } catch (ex) { 121 // If this is not an HTTP channel we can't and won't do anything. 122 } 123 124 // Don't honor the charset parameter and use UTF-8 (see bug 741776). 125 request.contentCharset = "UTF-8"; 126 this.decoder = new TextDecoder("UTF-8"); 127 128 // Changing the content type breaks saving functionality. Fix it. 129 fixSave(request); 130 131 // Start the request. 132 this.listener.onStartRequest(request); 133 134 // Initialize stuff. 135 const win = getWindowForRequest(request); 136 if (!win || !Components.isSuccessCode(request.status)) { 137 return; 138 } 139 140 // We compare actual pointer identities here rather than using .equals(), 141 // because if things went correctly then the document must have exactly 142 // the principal we reset it to above. If not, something went wrong. 143 if (win.document.nodePrincipal != resourcePrincipal) { 144 // Whatever that document is, it's not ours. 145 request.cancel(Cr.NS_BINDING_ABORTED); 146 return; 147 } 148 149 this.data = exportData(win, headers); 150 insertJsonData(win, this.data.json); 151 win.addEventListener("contentMessage", onContentMessage, false, true); 152 keepThemeUpdated(win); 153 154 // Send the initial HTML code. 155 const buffer = new TextEncoder().encode(initialHTML(win.document)).buffer; 156 const stream = new BufferStream(buffer, 0, buffer.byteLength); 157 this.listener.onDataAvailable(request, stream, 0, stream.available()); 158 }, 159 160 onStopRequest(request, statusCode) { 161 // Flush data if we haven't been canceled. 162 if (Components.isSuccessCode(statusCode)) { 163 this.decodeAndInsertBuffer(new ArrayBuffer(0), true); 164 } 165 166 // Stop the request. 167 this.listener.onStopRequest(request, statusCode); 168 this.listener = null; 169 this.decoder = null; 170 this.data = null; 171 }, 172 173 // Decodes an ArrayBuffer into a string and inserts it into the page. 174 decodeAndInsertBuffer(buffer, flush = false) { 175 // Decode the buffer into a string. 176 const data = this.decoder.decode(buffer, { stream: !flush }); 177 178 // Using `appendData` instead of `textContent +=` is important to avoid 179 // repainting previous data. 180 this.data.json.appendData(data); 181 }, 182 }; 183 184 // Lets "save as" save the original JSON, not the viewer. 185 // To save with the proper extension we need the original content type, 186 // which has been replaced by application/vnd.mozilla.json.view 187 function fixSave(request) { 188 let match; 189 if (request instanceof Ci.nsIHttpChannel) { 190 try { 191 const header = request.getResponseHeader("Content-Type"); 192 match = header.match(/^(application\/(?:[^;]+\+)?json)(?:;|$)/); 193 } catch (err) { 194 // Handled below 195 } 196 } else { 197 const uri = request.QueryInterface(Ci.nsIChannel).URI.spec; 198 match = uri.match(/^data:(application\/(?:[^;,]+\+)?json)[;,]/); 199 } 200 let originalType; 201 if (match) { 202 originalType = match[1]; 203 } else { 204 originalType = "application/json"; 205 } 206 request.QueryInterface(Ci.nsIWritablePropertyBag); 207 request.setProperty("contentType", originalType); 208 } 209 210 function getHttpHeaders(request) { 211 const headers = { 212 response: [], 213 request: [], 214 }; 215 // The request doesn't have to be always nsIHttpChannel 216 // (e.g. in case of data: URLs) 217 if (request instanceof Ci.nsIHttpChannel) { 218 request.visitResponseHeaders({ 219 visitHeader(name, value) { 220 headers.response.push({ name, value }); 221 }, 222 }); 223 request.visitRequestHeaders({ 224 visitHeader(name, value) { 225 headers.request.push({ name, value }); 226 }, 227 }); 228 } 229 return headers; 230 } 231 232 let jsonViewStringDict = null; 233 function getAllStrings() { 234 if (!jsonViewStringDict) { 235 jsonViewStringDict = {}; 236 for (const string of jsonViewStrings.getSimpleEnumeration()) { 237 jsonViewStringDict[string.key] = string.value; 238 } 239 } 240 return jsonViewStringDict; 241 } 242 243 // The two following methods are duplicated from NetworkHelper.sys.mjs 244 // to avoid pulling the whole NetworkHelper as a dependency during 245 // initialization. 246 247 /** 248 * Gets the nsIDOMWindow that is associated with request. 249 * 250 * @param nsIHttpChannel request 251 * @returns nsIDOMWindow or null 252 */ 253 function getWindowForRequest(request) { 254 try { 255 return getRequestLoadContext(request).associatedWindow; 256 } catch (ex) { 257 // On some request notificationCallbacks and loadGroup are both null, 258 // so that we can't retrieve any nsILoadContext interface. 259 // Fallback on nsILoadInfo to try to retrieve the request's window. 260 // (this is covered by test_network_get.html and its CSS request) 261 return request.loadInfo.loadingDocument?.defaultView; 262 } 263 } 264 265 /** 266 * Gets the nsILoadContext that is associated with request. 267 * 268 * @param nsIHttpChannel request 269 * @returns nsILoadContext or null 270 */ 271 function getRequestLoadContext(request) { 272 try { 273 return request.notificationCallbacks.getInterface(Ci.nsILoadContext); 274 } catch (ex) { 275 // Ignore. 276 } 277 278 try { 279 return request.loadGroup.notificationCallbacks.getInterface( 280 Ci.nsILoadContext 281 ); 282 } catch (ex) { 283 // Ignore. 284 } 285 286 return null; 287 } 288 289 // Exports variables that will be accessed by the non-privileged scripts. 290 function exportData(win, headers) { 291 const json = new win.Text(); 292 // This pref allows using a deploy preview or local development version of 293 // the profiler, and also allows tests to avoid hitting the network. 294 const profilerUrl = Services.prefs.getStringPref( 295 "devtools.performance.recording.ui-base-url", 296 "https://profiler.firefox.com" 297 ); 298 const sizeProfilerEnabled = Services.prefs.getBoolPref( 299 "devtools.jsonview.size-profiler.enabled", 300 false 301 ); 302 const JSONView = Cu.cloneInto( 303 { 304 headers, 305 json, 306 readyState: "uninitialized", 307 Locale: getAllStrings(), 308 profilerUrl, 309 sizeProfilerEnabled, 310 }, 311 win, 312 { 313 wrapReflectors: true, 314 } 315 ); 316 try { 317 Object.defineProperty(Cu.waiveXrays(win), "JSONView", { 318 value: JSONView, 319 configurable: true, 320 enumerable: true, 321 writable: true, 322 }); 323 } catch (error) { 324 console.error(error); 325 } 326 return { json }; 327 } 328 329 // Builds an HTML string that will be used to load stylesheets and scripts. 330 function initialHTML(doc) { 331 // Creates an element with the specified type, attributes and children. 332 function element(type, attributes = {}, children = []) { 333 const el = doc.createElement(type); 334 for (const [attr, value] of Object.entries(attributes)) { 335 el.setAttribute(attr, value); 336 } 337 el.append(...children); 338 return el; 339 } 340 341 let os; 342 const platform = Services.appinfo.OS; 343 if (platform.startsWith("WINNT")) { 344 os = "win"; 345 } else if (platform.startsWith("Darwin")) { 346 os = "mac"; 347 } else { 348 os = "linux"; 349 } 350 351 const baseURI = "resource://devtools/client/jsonview/"; 352 353 return ( 354 "<!DOCTYPE html>\n" + 355 element( 356 "html", 357 { 358 platform: os, 359 class: "theme-" + getTheme(), 360 dir: Services.locale.isAppLocaleRTL ? "rtl" : "ltr", 361 }, 362 [ 363 element("head", {}, [ 364 element("meta", { 365 "http-equiv": "Content-Security-Policy", 366 content: kCSP, 367 }), 368 element("link", { 369 rel: "stylesheet", 370 type: "text/css", 371 href: "chrome://devtools-jsonview-styles/content/main.css", 372 }), 373 ]), 374 element("body", {}, [ 375 element("div", { id: "content" }, [element("div", { id: "json" })]), 376 element("script", { 377 src: baseURI + "json-viewer.mjs", 378 type: "module", 379 // This helps ensure that the ES Module get evaluated early, 380 // even if the HTTP request is transmitted in chunks (browser_jsonview_chunked_json.js). 381 async: "true", 382 }), 383 ]), 384 ] 385 ).outerHTML 386 ); 387 } 388 389 // We insert the received data into a text node, which should be appended into 390 // the #json element so that the JSON is still displayed even if JS is disabled. 391 // However, the HTML parser is not synchronous, so this function uses a mutation 392 // observer to detect the creation of the element. Then the text node is appended. 393 function insertJsonData(win, json) { 394 new win.MutationObserver(function (mutations, observer) { 395 for (const { target, addedNodes } of mutations) { 396 if (target.nodeType == 1 && target.id == "content") { 397 for (const node of addedNodes) { 398 if (node.nodeType == 1 && node.id == "json") { 399 observer.disconnect(); 400 node.append(json); 401 return; 402 } 403 } 404 } 405 } 406 }).observe(win.document, { 407 childList: true, 408 subtree: true, 409 }); 410 } 411 412 function keepThemeUpdated(win) { 413 const listener = function () { 414 win.document.documentElement.className = "theme-" + getTheme(); 415 }; 416 addThemeObserver(listener); 417 win.addEventListener( 418 "unload", 419 function () { 420 removeThemeObserver(listener); 421 win = null; 422 }, 423 { once: true } 424 ); 425 } 426 427 // Chrome <-> Content communication 428 function onContentMessage(e) { 429 // Do not handle events from different documents. 430 const win = this; 431 if (win != e.target) { 432 return; 433 } 434 435 const value = e.detail.value; 436 switch (e.detail.type) { 437 case "save": 438 win.docShell.messageManager.sendAsyncMessage( 439 "devtools:jsonview:save", 440 value 441 ); 442 } 443 } 444 445 function createInstance() { 446 return new Converter(); 447 } 448 449 exports.JsonViewService = { 450 createInstance, 451 };