SvelteKit Web Worker

How to create and communicate with a web worker in SvelteKit and ViteJS

Jul 7, 2022

#javascript #svelte #vitejs #programming

In the line of fire

Photo by Christopher Burns on Unsplash

I ❀️ web workers!

These background threads are my go to strategy to compute anything I consider as too expensive for a frontend web application (e.g. Tie Tracker) or if it has to execute a recurring tasks (e.g. Papyrs or Cycles.watch).

In these two last projects, I used SvelteKit. So here is how you can develop and communicate with web workers using such an application framework.


Create a web worker

There is no particular requirements nor convention but I like to suffix my worker files with the extension .worker.ts - e.g. to create a bare minimum worker I create a file named my.worker.ts which finds place in the src/lib directory.

onmessage = () => { console.log("Hello World πŸ‘‹"); }; export {};

onmessage is a function that fires each time the web worker gets called - i.e. each time postMessage is used to send a message to the worker from the window side.

excalidraw-1657189373053.webp

The empty export {} is a handy way to make the worker a module and solve following TypeScript error if not declared:

TS1208: 'my.worker.ts' cannot be compiled under '--isolatedModules' because it is considered a global script file. Add an import, export, or an empty 'export {}' statement to make it a module.


Dynamically import

To integrate the worker in a component it needs to be dynamically imported. SvelteKit relying on ViteJS for its tooling, it can be imported with the ?worker suffix (see documentation).

<script lang="ts"> import { onMount } from 'svelte'; let syncWorker: Worker | undefined = undefined; const loadWorker = async () => { const SyncWorker = await import('$lib/my.worker?worker'); syncWorker = new SyncWorker.default(); }; onMount(loadWorker); </script> <h1>Web worker demo</h1>

In above snippet I affect the worker to a local variable in case I would like to use it elsewhere in the component. Notably on onDestroy callback if I would clean up or propagate the destruction to the worker.


PostMessage: window -> web worker

To send a message from the window side to the web worker we need a reference to the loaded module. For such purpose we can use the variable I declared in previous chapter. e.g. we can send an empty message - empty object - right after the initialization.

const loadWorker = async () => { const SyncWorker = await import("$lib/my.worker?worker"); syncWorker = new SyncWorker.default(); syncWorker.postMessage({}); };

Assuming everything goes according plan, the web worker should receive the message and log to the console the "Hello World πŸ‘‹".

capture-d%E2%80%99e%CC%81cran-2022-07-07-a%CC%80-13.00.02.png

If you face the issue "SyntaxError: import declarations may only appear at top level of a module" while following this article, note that the use of web workers in development is only supported in selected browsers (see issue #4586 in ViteJS).


PostMessage: web worker -> window

Messages are bidirectional - i.e. web worker can also send messages to the window side. To do so, postMessage is used as well. The only difference with previous transmission channel is that it does not need an object or module as reference to trigger the message.

onmessage = () => { console.log("Hello World πŸ‘‹"); postMessage({}); }; export {};

In above snippet I send an empty message object from the web worker to the window each time the worker receives a messages (#yolo).

To intercept these messages on the window side, a function that fires every time such messages are send need to be registered. The loaded worker exposes an onmessage property for such purpose.

<script lang="ts"> import { onMount } from 'svelte'; let syncWorker: Worker | undefined = undefined; const onWorkerMessage = () => { console.log('Cool it works out πŸ˜ƒ'); }; const loadWorker = async () => { const SyncWorker = await import('$lib/my.worker?worker'); syncWorker = new SyncWorker.default(); syncWorker.onmessage = onWorkerMessage; syncWorker.postMessage({}); }; onMount(loadWorker); </script>

If I refresh my test browser and console, previous message "Hello World πŸ‘‹" is still printed but, in addition, the new message "Cool it works out πŸ˜ƒ" is added to the console as well.

capture-d%E2%80%99e%CC%81cran-2022-07-07-a%CC%80-13.08.51.png


TypeScript

Sending empty messages is cute for demo purpose but you might want to define some TypeScript definition to improve the code.

What I generally do is defining some types for the requests and responses that are identified with a typed identificator and contains information next to them. e.g. I often use a variable named msg and a data object that effectively contains the information.

export interface PostMessageDataRequest { text: string; } export interface PostMessageDataResponse { text: string; } export type PostMessageRequest = "request1" | "start" | "stop"; export type PostMessageResponse = "response1" | "response2"; export interface PostMessage<T extends PostMessageDataRequest | PostMessageDataResponse> { msg: PostMessageRequest | PostMessageResponse; data?: T; }

Again here, no particular reason or best practice, just a thing I do to clean up my code πŸ˜„.

Thanks to these definitions, I can now set types for the onmessage function of the web worker.

import type { PostMessage, PostMessageDataRequest } from "./post-message"; onmessage = ({ data: { data, msg } }: MessageEvent<PostMessage<PostMessageDataRequest>>) => { console.log(msg, data); const message: PostMessage<PostMessageDataRequest> = { msg: "response1", data: { text: "Cool it works out v2 πŸ₯³" } }; postMessage(message); }; export {};

Likewise, types can be defined in the component.

<script lang="ts"> import { onMount } from 'svelte'; import type { PostMessage, PostMessageDataRequest, PostMessageDataResponse } from '../lib/post-message'; let syncWorker: Worker | undefined = undefined; const onWorkerMessage = ({ data: { msg, data } }: MessageEvent<PostMessage<PostMessageDataResponse>>) => { console.log(msg, data); }; const loadWorker = async () => { const SyncWorker = await import('$lib/my.worker?worker'); syncWorker = new SyncWorker.default(); syncWorker.onmessage = onWorkerMessage; const message: PostMessage<PostMessageDataRequest> = { msg: 'request1', data: { text: 'Hello World v2 πŸ€ͺ' } }; syncWorker.postMessage(message); }; onMount(loadWorker); </script>

As I now use objects and modified the information that are transmitted, the messages printed in the console of my browser now reflects these changes.

capture-d%E2%80%99e%CC%81cran-2022-07-07-a%CC%80-13.35.26.png


Cronjob

As mentioned in the introduction, I use web worker to execute recurring tasks - to schedule cronjob. e.g in Papyrs, I use web worker jobs to synchronize the content of the blog posts that are edited and saved on the client side with the Internet Computer. Thanks to this approach, the user gets a smooth experience as the UI is not affected by any network communication.

When I implement such a timer in a web worker, I always define "start" and "stop" functions. The first being called when the web worker is loaded on the window side and the second being called when the related component that uses it gets destroyed.

import type { PostMessage, PostMessageDataRequest } from "./post-message"; onmessage = ({ data: { msg } }: MessageEvent<PostMessage<PostMessageDataRequest>>) => { switch (msg) { case "start": startTimer(); break; case "stop": stopTimer(); } }; let timer: NodeJS.Timeout | undefined = undefined; const print = () => console.log(`Timer ${performance.now()}ms ⏱`); const startTimer = () => (timer = setInterval(print, 1000)); const stopTimer = () => { if (!timer) { return; } clearInterval(timer); timer = undefined; }; export {};

To schedule the time I use setInterval which repeatedly calls a function or executes a code snippet, with a fixed time delay between each call. In above example, it log to the console every second.

On the window side, I use postMessage again to trigger the two events.

<script lang="ts"> import { onDestroy, onMount } from 'svelte'; let syncWorker: Worker | undefined = undefined; const loadWorker = async () => { const SyncWorker = await import('$lib/my.worker?worker'); syncWorker = new SyncWorker.default(); syncWorker.postMessage({ msg: 'start' }); }; onMount(loadWorker); onDestroy(() => syncWorker?.postMessage({ msg: 'stop' })); </script>

After updating my browser one last time, I find out console.log that are effectively printed according the interval I defined in my web worker.

capture-d%E2%80%99e%CC%81cran-2022-07-07-a%CC%80-13.46.53.png


Conclusion

Web worker are pure coding joy to me πŸ€“.

To infinity and beyond
David