/* 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";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
NetUtil:
"resource://gre/modules/NetUtil.sys.mjs",
});
const {
getTheme,
addThemeObserver,
removeThemeObserver,
} = require(
"resource://devtools/client/shared/theme.js");
const BinaryInput = Components.Constructor(
"@mozilla.org/binaryinputstream;1",
"nsIBinaryInputStream",
"setInputStream"
);
const BufferStream = Components.Constructor(
"@mozilla.org/io/arraybuffer-input-stream;1",
"nsIArrayBufferInputStream",
"setData"
);
const kCSP =
"default-src 'none'; script-src resource:; img-src 'self';";
// Localization
loader.lazyGetter(
this,
"jsonViewStrings", () => {
return Services.strings.createBundle(
"chrome://devtools/locale/jsonview.properties"
);
});
/**
* This object detects 'application/vnd.mozilla.json.view' content type
* and converts it into a JSON Viewer application that allows simple
* JSON inspection.
*
* Inspired by JSON View: https://github.com/bhollis/jsonview/
*/
function Converter() {}
Converter.prototype = {
QueryInterface: ChromeUtils.generateQI([
"nsIStreamConverter",
"nsIStreamListener",
"nsIRequestObserver",
]),
get wrappedJSObject() {
return this;
},
/**
* This component works as such:
* 1. asyncConvertData captures the listener
* 2. onStartRequest fires, initializes stuff, modifies the listener
* to match our output type
* 3. onDataAvailable decodes and inserts data into a text node
* 4. onStopRequest flushes data and spits back to the listener
* 5. convert does nothing, it's just the synchronous version
* of asyncConvertData
*/
convert(fromStream) {
return fromStream;
},
asyncConvertData(fromType, toType, listener) {
this.listener = listener;
},
getConvertedType(_fromType, channel) {
if (channel
instanceof Ci.nsIMultiPartChannel) {
throw new Components.Exception(
"JSONViewer doesn't support multipart responses.",
Cr.NS_ERROR_FAILURE
);
}
return "text/html";
},
onDataAvailable(request, inputStream, offset, count) {
// Decode and insert data.
const buffer =
new ArrayBuffer(count);
new BinaryInput(inputStream).readArrayBuffer(count, buffer);
this.decodeAndInsertBuffer(buffer);
},
onStartRequest(request) {
// Set the content type to HTML in order to parse the doctype, styles
// and scripts. The JSON will be manually inserted as text.
request.QueryInterface(Ci.nsIChannel);
request.contentType =
"text/html";
// Tweak the request's principal in order to allow the related HTML document
// used to display raw JSON to be able to load resource://devtools files
// from the jsonview document.
const uri = lazy.NetUtil.newURI(
"resource://devtools/client/jsonview/");
const resourcePrincipal =
Services.scriptSecurityManager.createContentPrincipal(
uri,
request.loadInfo.originAttributes
);
request.owner = resourcePrincipal;
const headers = getHttpHeaders(request);
// Enforce strict CSP:
try {
request.QueryInterface(Ci.nsIHttpChannel);
request.setResponseHeader(
"Content-Security-Policy", kCSP,
false);
request.setResponseHeader(
"Content-Security-Policy-Report-Only",
"",
false
);
}
catch (ex) {
// If this is not an HTTP channel we can't and won't do anything.
}
// Don't honor the charset parameter and use UTF-8 (see bug 741776).
request.contentCharset =
"UTF-8";
this.decoder =
new TextDecoder(
"UTF-8");
// Changing the content type breaks saving functionality. Fix it.
fixSave(request);
// Start the request.
this.listener.onStartRequest(request);
// Initialize stuff.
const win = getWindowForRequest(request);
if (!win || !Components.isSuccessCode(request.status)) {
return;
}
// We compare actual pointer identities here rather than using .equals(),
// because if things went correctly then the document must have exactly
// the principal we reset it to above. If not, something went wrong.
if (win.document.nodePrincipal != resourcePrincipal) {
// Whatever that document is, it's not ours.
request.cancel(Cr.NS_BINDING_ABORTED);
return;
}
this.data = exportData(win, headers);
insertJsonData(win,
this.data.json);
win.addEventListener(
"contentMessage", onContentMessage,
false,
true);
keepThemeUpdated(win);
// Send the initial HTML code.
const buffer =
new TextEncoder().encode(initialHTML(win.document)).buffer;
const stream =
new BufferStream(buffer, 0, buffer.byteLength);
this.listener.onDataAvailable(request, stream, 0, stream.available());
},
onStopRequest(request, statusCode) {
// Flush data if we haven't been canceled.
if (Components.isSuccessCode(statusCode)) {
this.decodeAndInsertBuffer(
new ArrayBuffer(0),
true);
}
// Stop the request.
this.listener.onStopRequest(request, statusCode);
this.listener =
null;
this.decoder =
null;
this.data =
null;
},
// Decodes an ArrayBuffer into a string and inserts it into the page.
decodeAndInsertBuffer(buffer, flush =
false) {
// Decode the buffer into a string.
const data =
this.decoder.decode(buffer, { stream: !flush });
// Using `appendData` instead of `textContent +=` is important to avoid
// repainting previous data.
this.data.json.appendData(data);
},
};
// Lets "save as" save the original JSON, not the viewer.
// To save with the proper extension we need the original content type,
// which has been replaced by application/vnd.mozilla.json.view
function fixSave(request) {
let match;
if (request
instanceof Ci.nsIHttpChannel) {
try {
const header = request.getResponseHeader(
"Content-Type");
match = header.match(/^(application\/(?:[^;]+\+)?json)(?:;|$)/);
}
catch (err) {
// Handled below
}
}
else {
const uri = request.QueryInterface(Ci.nsIChannel).URI.spec;
match = uri.match(/^data:(application\/(?:[^;,]+\+)?json)[;,]/);
}
let originalType;
if (match) {
originalType = match[1];
}
else {
originalType =
"application/json";
}
request.QueryInterface(Ci.nsIWritablePropertyBag);
request.setProperty(
"contentType", originalType);
}
function getHttpHeaders(request) {
const headers = {
response: [],
request: [],
};
// The request doesn't have to be always nsIHttpChannel
// (e.g. in case of data: URLs)
if (request
instanceof Ci.nsIHttpChannel) {
request.visitResponseHeaders({
visitHeader(name, value) {
headers.response.push({ name, value });
},
});
request.visitRequestHeaders({
visitHeader(name, value) {
headers.request.push({ name, value });
},
});
}
return headers;
}
let jsonViewStringDict =
null;
function getAllStrings() {
if (!jsonViewStringDict) {
jsonViewStringDict = {};
for (
const string of jsonViewStrings.getSimpleEnumeration()) {
jsonViewStringDict[string.key] = string.value;
}
}
return jsonViewStringDict;
}
// The two following methods are duplicated from NetworkHelper.sys.mjs
// to avoid pulling the whole NetworkHelper as a dependency during
// initialization.
/**
* Gets the nsIDOMWindow that is associated with request.
*
* @param nsIHttpChannel request
* @returns nsIDOMWindow or null
*/
function getWindowForRequest(request) {
try {
return getRequestLoadContext(request).associatedWindow;
}
catch (ex) {
// On some request notificationCallbacks and loadGroup are both null,
// so that we can't retrieve any nsILoadContext interface.
// Fallback on nsILoadInfo to try to retrieve the request's window.
// (this is covered by test_network_get.html and its CSS request)
return request.loadInfo.loadingDocument?.defaultView;
}
}
/**
* Gets the nsILoadContext that is associated with request.
*
* @param nsIHttpChannel request
* @returns nsILoadContext or null
*/
function getRequestLoadContext(request) {
try {
return request.notificationCallbacks.getInterface(Ci.nsILoadContext);
}
catch (ex) {
// Ignore.
}
try {
return request.loadGroup.notificationCallbacks.getInterface(
Ci.nsILoadContext
);
}
catch (ex) {
// Ignore.
}
return null;
}
// Exports variables that will be accessed by the non-privileged scripts.
function exportData(win, headers) {
const json =
new win.Text();
const JSONView = Cu.cloneInto(
{
headers,
json,
readyState:
"uninitialized",
Locale: getAllStrings(),
},
win,
{
wrapReflectors:
true,
}
);
try {
Object.defineProperty(Cu.waiveXrays(win),
"JSONView", {
value: JSONView,
configurable:
true,
enumerable:
true,
writable:
true,
});
}
catch (error) {
console.error(error);
}
return { json };
}
// Builds an HTML string that will be used to load stylesheets and scripts.
function initialHTML(doc) {
// Creates an element with the specified type, attributes and children.
function element(type, attributes = {}, children = []) {
const el = doc.createElement(type);
for (
const [attr, value] of Object.entries(attributes)) {
el.setAttribute(attr, value);
}
el.append(...children);
return el;
}
let os;
const platform = Services.appinfo.OS;
if (platform.startsWith(
"WINNT")) {
os =
"win";
}
else if (platform.startsWith(
"Darwin")) {
os =
"mac";
}
else {
os =
"linux";
}
const baseURI =
"resource://devtools/client/jsonview/";
return (
"\n" +
element(
"html",
{
platform: os,
class:
"theme-" + getTheme(),
dir: Services.locale.isAppLocaleRTL ?
"rtl" :
"ltr",
},
[
element(
"head", {}, [
element(
"meta", {
"http-equiv":
"Content-Security-Policy",
content: kCSP,
}),
element(
"link", {
rel:
"stylesheet",
type:
"text/css",
href:
"chrome://devtools-jsonview-styles/content/main.css",
}),
]),
element(
"body", {}, [
element(
"div", { id:
"content" }, [element(
"div", { id:
"json" })]),
element(
"script", {
src: baseURI +
"lib/require.js",
"data-main": baseURI +
"viewer-config.js",
}),
]),
]
).outerHTML
);
}
// We insert the received data into a text node, which should be appended into
// the #json element so that the JSON is still displayed even if JS is disabled.
// However, the HTML parser is not synchronous, so this function uses a mutation
// observer to detect the creation of the element. Then the text node is appended.
function insertJsonData(win, json) {
new win.MutationObserver(
function (mutations, observer) {
for (
const { target, addedNodes } of mutations) {
if (target.nodeType == 1 && target.id ==
"content") {
for (
const node of addedNodes) {
if (node.nodeType == 1 && node.id ==
"json") {
observer.disconnect();
node.append(json);
return;
}
}
}
}
}).observe(win.document, {
childList:
true,
subtree:
true,
});
}
function keepThemeUpdated(win) {
const listener =
function () {
win.document.documentElement.className =
"theme-" + getTheme();
};
addThemeObserver(listener);
win.addEventListener(
"unload",
function () {
removeThemeObserver(listener);
win =
null;
},
{ once:
true }
);
}
// Chrome <-> Content communication
function onContentMessage(e) {
// Do not handle events from different documents.
const win =
this;
if (win != e.target) {
return;
}
const value = e.detail.value;
switch (e.detail.type) {
case "save":
win.docShell.messageManager.sendAsyncMessage(
"devtools:jsonview:save",
value
);
}
}
function createInstance() {
return new Converter();
}
exports.JsonViewService = {
createInstance,
};