How does MDN Intercept `console.log`?
Is it wizardry or trickery?
In the last post we looked at:
- Uploading from WebStorm to GitHub
- Semantic Versioning
- Conventional Commits
In this post, we want to resume developing our code editor. The basic objective is to create a box that the user can enter some code and, with the touch of a button, can see the result of their code.
First Iteration Behaviour
The first implementation was design around the eval
function, the result of which would be displayed in the result pane. Putting in a string โhelloโ would result in hello
being displayed. Additionally, assigning a variable and then putting that variable alone on the last line, the result pane displays the value of the variable.
Instead, it would be more intuitive to use console.log
instead, which is what MDN has done in their interactive examples.
Background for the Second Iteration
In NodeJS, itโs possible to read the process.stdout
stream, or create a new logger with custom streams. So, I looked for resources on how to hook the output of the browserโs console.log
. From what I could understand, there is no way to get the output of console
statements in the browser.
However, it is possible to intercept the arguments of console.log
when called. A StackOverflow answer has a solution. By reassigning the original console.log
statement to a new variable, we can assign our own custom function to console.log
. Note that this doesnโt intercept the output of log statements, but only intercepts the arguments.
How does MDN do it?
MDN seems to have the ability to read the console output, but upon further inspection, MDNโs interactive editor output is different to the browserโs console. This means that they must be handling the formatting themselves.
Looking at MDNโs minified editor-js
code, a new Function(t)()
is used instead of eval(t)
to parse and run the input. Thereโs also a try-catch
block to handle errors.
!function(t) {
d.classList.add("fade-in");
try {
new Function(t)()
} catch (t) {
d.textContent = "Error: " + t.message
}
d.addEventListener("animationend", function() {
d.classList.remove("fade-in")
})
}(e.getDoc().getValue())
Looking for a console.log
assignment, the following code was found in the same file:
var e = t("./console-utils")
, n = console.log
, r = console.error;
console.error = function(t) {
e.writeOutput(t),
r.apply(console, arguments)
},
console.log = function() {
for (var t = [], r = 0, i = arguments.length; r < i; r++) {
var o = e.formatOutput(arguments[r]);
t.push(o)
}
var a = t.join(" ");
e.writeOutput(a),
n.apply(console, arguments)
}
This code lines up nicely with the StackOverflow answer. Each argument is iterated through and formatted, then joined together. The original arguments are also passed back to the original console.log
.
Hunting for the Source Code
After some sleuthing, I discovered MDNโs Builder of Bits (BoB). Itโs the repository responsible for the interactive examples on MDN. Mozilla have also graciously given the repo a MIT license.
For example, this is the original source code for the minified code block above:
module.exports = function() {
'use strict';var consoleUtils = require('./console-utils');
var originalConsoleLogger = console.log; // eslint-disable-line no-console
var originalConsoleError = console.error;console.error = function(loggedItem) {
consoleUtils.writeOutput(loggedItem);
// do not swallow console.error
originalConsoleError.apply(console, arguments);
};// eslint-disable-next-line no-console
console.log = function() {
var formattedList = [];
for (var i = 0, l = arguments.length; i < l; i++) {
var formatted = consoleUtils.formatOutput(arguments[i]);
formattedList.push(formatted);
}
var output = formattedList.join(' ');
consoleUtils.writeOutput(output);
// do not swallow console.log
originalConsoleLogger.apply(console, arguments);
};
};
For those who are curious, the file responsible for formatting MDNโs logs is here: https://github.com/mdn/bob/blob/master/editor/js/editor-libs/console-utils.js. It has some rules that are responsible for formatting the log lines when the output of the native toString()
method does not suffice.
Looking at Other Options
After installing mdn-bob
I decided that the library was too specific to MDNโs use case. For example, the library included CSS styles that I didnโt need. I only needed a small segment from the code, the formatter.
NodeJS itself has a native util
library that has an inspect
method, which can format anything. After some searching on NPM for browser ports of util.inspect
, I settled on object-inspect
.
Even though the output may not be identical to the browser, I thought the convenience was a reasonable compromise. If my code is tidy, changing to a better library in the future should be easy.
Building the Second Iteration
Combining StackOverflowโs answer with MDN BoBโs, I started by assigning the original console statements.
const originalConsoleLogger = console.log;
const originalConsoleError = console.error;
Note that since console.log
is being called and not window.console.log
, we should not run into issues with Server Side Rendering (SSR).
I re-assigned console.log
and console.error
to my respective custom functions. These eventually need to go inside our React component since the outcome of handling the arguments should be an update to the component state.
console.error = function () {
// handle arguments
originalConsoleError.apply(console, arguments)
}console.log = function () {
// handle arguments
originalConsoleLogger.apply(console, arguments)
}
I noticed that we could convert this to ES6 syntax by using arrow functions as well as using rest parameters instead of arguments
. This approach is suggested by MDN. To preserve the symmetry between the function parameters and executing the original console function, I opted to use .call
instead of .apply
.
console.error = (...args) => {
// handle arguments
originalConsoleError.call(console, ...args)
}console.log = (...args) => {
// handle arguments
originalConsoleLogger.call(console, ...args)
}
The arguments will then need to be processed by the object-inspect
library. Instead of using a for
loop, I opted to use Array.reduce
. Although years of ESLint has trained me not to use the any
type, I think itโs acceptable in this instance since objectInspect
expects any
as an input. With so many โargsโ this is undoubtedly a pirateโs favourite function.
import objectInspect from "object-inspect";/* ... */const reduceArgs = (formattedList: any[], arg: any) => [
...formattedList,
objectInspect(arg),
];
const formatArgs = (args: any[]) => args.reduce(reduceArgs, []).join(" ");
Using Function Instead of Eval
Based on MDNโs advice, using Function
is faster and safer than eval
. Note that using either of these is not safe in most circumstances. In this case the code is provided by the user in their own browser and is not stored or reused anywhere else.
try {
new Function(code)();
} catch (e) {
console.error(e);
}
Bringing it Into React
All thatโs needed is to move each function inside the same scope as the setResult
and setError
states and update the state using the output of formatArgs
.
const StringPage = () => {
const [result, setResult] = React.useState("");
const [error, setError] = React.useState("");
const codeRef = React.useRef<HTMLTextAreaElement>(null); console.log = (...args: any[]) => {
const formattedLog = formatArgs(args);
setResult(formattedLog);
originalConsoleLogger.call(console, ...args);
};
console.error = function (...args: any[]) {
const formattedError = formatArgs(args);
setError(formattedError);
originalConsoleError.call(console, ...args);
}; const evaluateCode = () => {
if (codeRef.current === null) return;
const code = codeRef.current.value;
if(code.length < 1) return; try {
new Function(code)();
} catch (e) {
console.error(e);
}
}; return (
<>
{/* surrounding JSX removed for clarity */}
{result}
{error}
</>
);
TL; DR
The browserโs console
output canโt be read. MDNโs interactive examples override console.log
, formats the arguments for the webpage, and then calls the original console.log
. I created a component in React that accomplishes the same thing.