Moving Code Evaluation to a WebWorker | DevLog 009
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.
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
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.
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
.
// 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.