Bayan Bennett

How does MDN Intercept `console.log`? | DevLog 003

React JS
TypeScript
Video version of this post

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

first iteration

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.

mdn interactive example output interactive example console output

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 the 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.

© 2022 Bayan Bennett