Worker to Main#
We’ve been in isolation. Let’s get back into the browser by hooking the worker module up to the main module.
What? Why?#
Here are the layers of our cake:
A web page includes the main module
main.js
Main makes a worker module that it sends/receives messages with
The worker makes a Pyodide that it sends/receives calls with
In this step, we’ll do the second part. Our main module will:
Initialize a
Worker
Send the worker a message, telling it to initialize Pyodide
Receive a message from the worker, saying Pyodide is initialized
Update the
document
with info from that message
We want to stay in the “joyful” mode by using tests.
It will prove a little more complicated, as happy-dom
doesn’t really support main<->worker.
So we’ll still need to finish by confirming “end to end” in a browser.
We’ll automate that in the next step with Playwright.
Cleanup#
We’ll start by emptying main.js
to delete the “tracer” constant we did earlier.
Equally, we’ll edit main.test.js
to delete that test.
Make an uninitialized Worker
#
Let’s create the worker.
We’ll start with a test in main.test.js
:
test("Worker initialization", () => {
expect(Worker).to.exist;
});
And immediately, a problem.
Worker
is a global in browsers, but doesn’t exist in happy-dom
.
Thus, running this test produces:
ReferenceError: Worker is not defined
This is tricky as this happens as soon as we do an import. How can we inject a global before the import happens?
Back to tests/setup.js
and our Vitest setup.
Let’s add a MockWorker
and register it with Vitest’s vi.stubGlobal
:
const MockWorker = vi.fn(() => ({
postMessage: vi.fn(),
}));
if (!globalThis["worker"]) {
vi.stubGlobal("Worker", MockWorker);
self.postMessage = vi.fn();
}
Back to main.test.js
:
import {expect, test} from "vitest";
import {worker} from "../src/pyodide_components/main.js";
test("Worker initialization", () => {
expect(Worker).to.exist;
expect(worker.postMessage).to.exist;
});
Now an implementation in main.js
.
export const worker = new Worker("worker.js", {type: "module"});
We created a Worker
instance using the module worker option.
Our test passes.
Let’s now implement an initializer.
Initialize worker#
When the main module “wakes up” and makes the worker, it needs to tell the worker to “initialize”.
It appears to be simple: just add a postMessage
call:
worker.postMessage({messageType: "initialize"});
Then, go to main.test.js
and – like with worker.test.js
– install a spy on worker.postMessage
.
However, this reveals a flaw.
worker.postMessage
is at module scope and executes at import.
It’s already run before we get into a test and try to install a spy.
We need to refactor our code so that initialization doesn’t happen at import time. As a note, this can have benefits to the application. Conceivably, the main UI module could “restart” the worker and thus Pyodide.
Delayed initialization#
Let’s change our tests to first assert that, at import time, worker
exists but is uninitialized.
Another test will call initialize
and cause it to be assigned a Worker
instance:
import {expect, test} from "vitest";
import {worker, initialize} from "../src/pyodide_components/main.js";
test("has no initialized worker", () => {
expect(Worker).to.exist;
expect(worker).not.to.exist;
});
test("initializes workers", () => {
initialize();
expect(worker).to.exist;
});
You might already spot the issue: how will the web page instrument the calling of initialize
?
We’ll tackle that below.
Dispatcher table#
The main module will send messages to the worker. But it will also receive messages. We’ll have the equivalent of a dispatcher, using a little lookup table to find small message handler functions. These handlers can then be tested in isolation.
Let’s write some failing tests:
import {worker, initialize, messageHandlers} from "../src/pyodide_components/main.js";
test("has handler lookup table", () => {
expect(messageHandlers.initialized).to.exist;
});
Now in main.js
, let’s write the little dispatch table with an empty finishedInitialized
handler:
export function finishedInitialize(messageValue) {
}
export const messageHandlers = {
"initialized": finishedInitialize,
};
Our tests pass. Let’s now move on to the dispatcher. We’ll start with the easy part – handling invalid messages – which gets our first chunch in place.
Deal with invalid worker messages#
This part is a bit complicated, as we’ll ultimately have to install the postMessage
mock again.
We’ll get the basics in place by focusing first on dealing with unknown worker messages.
First, a test for an unknown message and a test for “initialized”:
import {dispatcher} from "../src/pyodide_components/worker.js";
test("rejects an invalid messageType", async () => {
const msg = {messageType: "xxx", messageValue: null};
const errorMsg = `No message handler for "xxx"`;
await expect(async () => await dispatcher(msg)).rejects.toThrowError(errorMsg);
});
Now the dispatcher
implementation:
export function dispatcher({messageType, messageValue}) {
if (!(messageType in messageHandlers)) {
throw `No message handler for "${messageType}"`;
}
messageHandlers[messageType](messageValue);
}
This lets us write a test for a valid message:
test("dispatches to finishedInitialize", async () => {
const spy = vi.spyOn(messageHandlers, "initialized");
const msg = {messageType: "initialized", messageValue: "Pyodide app is initialized"};
await dispatcher(msg);
expect(spy).toHaveBeenCalledWith("Pyodide app is initialized");
});
In this test, we spy on the initialized
handler.
We then ensure the dispatcher called it with the correct arguments.
Handler for initialized
#
Our finishedInitialize
function is unimplemented.
We’d like it to grab a <span id="status"></span>
node in the document.
Then, replace innerText
with the messageValue
.
First, a test.
Our Vitest environment uses happy-dom
as a fake DOM document.
We need to initialize it to the HTML we expect in our real document.
We’ll add a beforeEach
that sets up the document, then a test to ensure the <span>
is there:
import {beforeEach, expect, test, vi} from "vitest";
beforeEach(() => {
document.body.innerHTML = `<span id="status"></span>`;
});
test("document has a status node", () => {
const status = document.getElementById("status");
expect(status).to.exist;
expect(status.innerText).to.equal("");
});
With that in place, we can write a failing test:
test("updates document with initialized messageValue", async () => {
const status = document.getElementById("status");
const messageValue = "Loading is groovy";
await finishedInitialize(messageValue);
expect(status.innerText).to.equal(messageValue);
});
The implementation is really simple:
export function finishedInitialize(messageValue) {
const status = document.getElementById("status");
status.innerText = messageValue;
}
The test and implementation were simple for an important reason. We’ve adopted a development style where we can write small handler functions. These functions can be exported individually, then imported in a test.
The work is moved elsewhere for:
Registering a message handler
unpacking the agreed-upon message structure
Finding the right handler
Calling it with the right argument
Initialize when in a browser#
Before we can open this in a browser, we have to confront a decision made above.
Our initialize
function isn’t called anywhere.
We could put initialize()
at module scope.
But it would then be executed by the test at import time.
We need a way of knowing if we are running under a test, inside Happy DOM. Let’s arrange to set the “user agent”. First, a failing test:
test("has correct user agent", () => {
expect(navigator.userAgent).to.equal("Happy DOM");
});
Now in tests/setup.js
:
navigator.userAgent = "Happy DOM";
The test passes.
We can now add this to the end of main.js
:
if (navigator.userAgent !== "Happy DOM") {
// We are running in a browser, not in a test, so initialize.
initialize();
}
With this in place, we can finish our main.js
with a finished initialize
.
The arrow function for worker.onmessage
unpacks the data from the message before sending to the dispatcher.
export function initialize() {
worker = new Worker("worker.js", {type: "module"});
worker.onmessage = ({data}) => dispatcher(data);
worker.postMessage({messageType: "initialize"});
}
Back into the browser#
Let’s see if things are working ok in the browser. All we really need to add is some HTML for the status message:
<div>Status: <span id="status">Startup</span></div>
With this in place, if you open index.html
directly in a browser via an HTTP server, it works.
But:
Only in non-Firefox
Not when bundling with Vite
Why not in Firefox? A 7-year-old unimplemented feature. Firefox implements web workers, but not module workers…meaning, you can’t do ESM export/import in web workers. The ticket has recent activity.
In theory, a bundler like Vite is the answer. But Pyodide has some trouble with bundlers.
For the purpose of this series, we’ll keep going and just view in Chrome/Safari, with no bundling.