Bayan Bennett

Moving Code Evaluation to a WebWorker | DevLog 009

Next.js
TypeScript
Accompanying video

In this series I created a webpage with multiple code editors. However, the problem is: running the code in one editor would update the output of both editors with the same result. Obviously, this would confuse any user. The issue is that I was intercepting the page's console methods, and there was no clean way to distinguish which code editor was logging to the console.

code is duplicated in editor

Once I had some more time to think about the problem, I recalled that I could use Web Workers since they had their own scope.

Looking at Web Workers

Workers have something called a WorkerGlobalScope that is similar to, but separate from, the page's window. This isolation from the browser context can improve security and, since workers have their own event loop, they can improve performance as well.

NextJS v10 ➕ Webpack v5

Next js10 ships with an option to enable webpack version 5 which has a very convenient feature: it will support adding workers without using worker-loader.

new Worker(new URL("./worker.js", import.meta.url));

This might not look special; however, this URL is different than the regular URL that's supplied to the Worker class. Instead of the URL pointing to a path in the built folder, Webpack v5 enables pointing to a relative file. That file can even contain imports or be written in TypeScript. This is because the file is built by Webpack and then the URL is replaced with the location of the built file.

Communicating with Workers

window and worker communication

Inside the page, I can spawn a new worker and I'll send code to that worker to be evaluated. Inside the worker, calls to console.log can be collected and sent back to the page. The mechanism for sending and receiving messages on both the page and the worker is the postMessage and onmessage methods in their respective scopes. Below is a simplified example from my codebase of how to send and receive messages.

const worker = new Worker(
  new URL("./code-runner.worker.ts", import.meta.url)
);

/* ... */

const sendCode = (code) => new Promise((resolve, reject) => {
  worker.onerror = (e) => reject(e.message);
  worker.onmessage = ({ data }) => resolve(data);
  worker.postMessage(code);
});

Proxy Class in JS

This idea began as a shower thought. I recalled finding the Proxy class in JavaScript and I wondered if there was a use for it to intercept console.log. A Proxy can intercept and redefine fundamental operations for any object, which is exactly what I needed.

console proxy

The concept behind a JS Proxy is like a web proxy. Anything that receives an input can be encapsulated by a proxy, which can intercept calls, read the requests, and can apply logic to those requests.

In my case, I need to wrap console.log with a Proxy. When we call console.log(...args), the apply handler will be called in the Proxy and it will be given the intended target and the arguments.

There's also a handler called get, which will be called whenever a property of the Proxy is accessed. This allowed me to not only intercept console.log, but every method of console at once. The way that this works is that there are two proxies, the first one handles the get and returns a second proxy to handle the apply.

proxy get and apply handlers
// code-runner.worker.ts

type ConsoleKeys = keyof Console;

type ConsoleMethods = Console[ConsoleKeys];

type CreateApply = (
  level: ConsoleKeys
) => ProxyHandler<ConsoleMethods>["apply"];
const createApply: CreateApply = (level) => 
  (target, thisArg, argArray) => {
    messages.push({ level, argArray });
    return target.apply(thisArg, argArray);
  };

const get: ProxyHandler<Console>["get"] = (target, prop: ConsoleKeys) =>
  new Proxy(target[prop], {
    apply: createApply(prop),
  });

console = new Proxy(console, { get });

Console Message Collection

When the code is evaluated, messages will be populated with the arguments of calls to console methods. Once the code has finished executing, the messages are then sent back to the page and then cleared.

// code-runner.worker.ts

export type Message = {
  level: ConsoleKeys;
  argArray: any[];
};

let messages: Message[] = [];

/* ... */

self.onmessage = ({ data }: { data: string }) => {
  new Function(data)();
  self.postMessage(messages);
  messages = [];
};

TL; DR

Took advantage of WebWorkers' independent scope to intercept console methods away from the page context. This way, code can be evaluated individually. I also used the Proxy class to intercept all calls to all console methods.

© 2022 Bayan Bennett