DEV Community

Cover image for Using a WebWorker in the browser to create a plug-in architecture
Tracy Gilmore
Tracy Gilmore

Posted on • Edited on

Using a WebWorker in the browser to create a plug-in architecture

Very few business information systems these days use anything other than the web platform as the user interface. It is also regarded as wise to only include presentation logic in the frontend; pushing as much business logic into the backend as possible. However, there are always exceptions and in some specialist applications such "rules" are occasionally bent for sound architectural reasons.

Conventional Client-Server architecture

An increasingly popular architecture these days is the JAMStack where the frontend is hosted in the same way as a static website (via a CDN for example) but with more dynamic behaviour. In this architecture there tends to be some specific business logic in the frontend with the "heavy lifting", such as storage and authentication, provided in a generic manner through third-party APIs.

JAMStack architecture

This novel application design offers a great deal of resilience and flexibility but not necessarily the extensibility that can be achieved using a plug-in architecture. When an application requires greater extensibility or the ability to call on functionality not necessarily developed as part of the original application, or even by the same developers, a plug-in architecture is a candidate solution. As described below, there are several possible approaches but using a web worker can provide significant benefits.

Desktop application with plug-in extensions

In order to discuss a variety of approaches around a common context we will start by describing an example use case (application) used to exercise and demonstrate.

Test scenario

In this very simple example (honestly I do not think I could envisage a simpler) we will perform the four basic mathematical operations (addition, subtraction, multiplication, division) on two numbers and return the answer. Plugins will provide the actual arithmetic operations.

The 'application' will provide the inputs and present the output. In addition the program will;

  • load the list of available plugins (manifest)
  • present buttons for each plugin
  • when the button is pressed, the plugin will be loaded, passed the inputs and execute with the output returned for presentation.

All four plugins will expose a single 'calculate' function that accepts two numeric inputs and returns the result.

Example Plugin: addition.js

function calculate(num1, num2) { return num1 + num2; } 
Enter fullscreen mode Exit fullscreen mode

List of plugins (manifest.json)

[ { "pluginName": "Addition", "pluginFile": "addition.js" } ] 
Enter fullscreen mode Exit fullscreen mode

A working example can be found in my web-worker GitHub repository.


Exploring candidate solutions

How can a plug-in architecture be achieved when desktop applications employ web technologies and, more specifically, operate in the web browser. The answer, or at least part of it, is the Web Worker API. Granted, I am discussing a rather exotic requirement in the wide world of web applications but as the capability of the web browser increases such potential solutions become possible.

In a plugin architecture it is usual for the application to load a specific file at runtime, which we will call the manifest, that details what plugins are available and how to find/load them. This file could be sourced from outside the application easily using a AJAX/XHR request and having the data in JSON format.

The internals of a web application with plugin extensions

Before making the plug-in model available for 'foreign' developers the application developers need to establish a Software Development Kit (SDK). As a minimum the SDK should be a document outlining the API ('api'), which outlines the interface the application expects all plugins to support. In addition, the SDK can provide test code developers can use to exercise and validate their plugin before using it with the application.

Some less effective alternatives

Before we dive deeper into the technicalities of the Web Worker mechanism, we will explore a couple of ways in which similar functionality can be achieved and their limitation.

At the crux of the following examples is need for a mechanism to load scripts on demand and with the name of the file/module supplied at runtime. The name of the "plugins" are not know at build time, but supplied through a file loaded at runtime, and certainly not included in the application bundle.

In the example code we have to demonstrate each of the approaches described below there is a single application (index.html) with a script element for each approach. For each script there is a loosely-coupled plug-in (JS) file.

index.html

<body> <script src="dom-injection.js"></script> <script type="module" src="dynamic-import.js"></script> </body> 
Enter fullscreen mode Exit fullscreen mode

DOM inject

dom-injection.js

console.log('dom-injection'); function loadScript(scriptPath, functionName) { window[functionName] = () => console.log(`${functionName} is not yet loaded`); const scriptElement = document.createElement('script'); scriptElement.type = 'text/javascript'; scriptElement.async = true; scriptElement.src = scriptPath; scriptElement.onload = () => { console.log(`${functionName}() is now ready`); }; document.body.appendChild(scriptElement); } loadScript('./hello-world.js', 'helloWorld'); 
Enter fullscreen mode Exit fullscreen mode

hello-world.js

function helloWorld() { alert('Hello, World! (injection)'); } 
Enter fullscreen mode Exit fullscreen mode

1) injection of script tags into the DOM
2) Pollution of the global namespace

Dynamic imports

dynamic-import.js

console.log('dynamic-import'); async function loadScript(path, functionName) { window[functionName] = () => console.log(`${functionName} is not yet loaded`); const module = await import(path); window[functionName] = module.default; console.log(`${functionName}() is now ready`); } loadScript('./hello-world.module.js', 'modHelloWorld'); 
Enter fullscreen mode Exit fullscreen mode

hello-world.module.js

function helloWorld() { alert('Hello, World! (import)'); } export default helloWorld; 
Enter fullscreen mode Exit fullscreen mode

1) Web bundlers
2) Short lived / not cacheable

Both: execution in the primary thread

Output

Viewing the index.html in the web browser will produce the following output in the developer console.

output

This shows the two scripts loading followed by the two loadScript functions running to load the stipulated plugin script and execute the named function it contains. In both scenarios the function is initialised with a temporary version that is never executed but prevents an error being reported should the function be called before it is loaded.


A worked example (Web Worker)

Let's start by listing the 'moving' parts:

  • The application that will utilise the plugins
  • The manifest file that details what plugins are available at runtime
  • The web-worker code that will load, execute (and potentially cache) plugins
  • Finally, the plugins themselves.

The Scenario

We will use simple mathematical operations, each their own plug-in, to perform Celsius and Fahrenheit temperature conversion. To recap, here are the calculations.

Fahrenheit = (Celsius * 9) / 5 + 32 Celsius = (Fahrenheit - 32) * 5 / 9 
Enter fullscreen mode Exit fullscreen mode

These are the test cases we will use to exercise the process.

-40°C => -40°F 0°C => 32°F 32°F => 0°C 50°C => 122°F 122°F => 50°C 100°C => 212°F 212°F => 100°C 
Enter fullscreen mode Exit fullscreen mode

The main application file will load the test cases as a JSON file and initialise the primary Web Worker. Each test case will be sent one-by-one to the primary worker for calculation and the response output by the application code. The initial call to the primary worker will include a reference to the manifest that will be loaded in preparation for subsequent calls. For each test case called three plug-ins will be used, with the first use also involving loading of the plug-in itself.

Exercising each test case will involve splitting the input string into its measurement and scale components. The measurement will be converted into a number whilst the scale will identify which formula is to be adopted and therefore which plug-ins will be called.

NB: In the following code example I have deliberately excluded any defensive code to keep it on topic. The source code for this project can be found in this GitHub repo and comprises of around a dozen files.

Simplified solution

Prior to demonstrating the web worker-based plug-in solution we will review the script section of the all-in-one sampler.

const testData = [ ['-40°C', '-40°F'], ['0°C', '32°F'], ['50°C', '122°F'], ['100°C', '212°F'], ]; const add = (x, y) => x + y; const sub = (x, y) => x - y; const mul = (x, y) => x * y; const div = (x, y) => x / y; console.table( testData.flat().map((testDatum) => ({ testcase: testDatum, result: convertTemperature(testDatum), })) ); function convertTemperature(datum) { const cToF = (t) => add(div(mul(t, 9), 5), 32); const fToC = (t) => div(mul(sub(t, 32), 5), 9); const [temp, scale] = datum.split(/°/); return scale === 'C' ? `${cToF(+temp)}°F` : `${fToC(+temp)}°C`; } 
Enter fullscreen mode Exit fullscreen mode

In the above source code we have the test data as an array at the top, followed by the four arithmetic operations we will contain as plug-ins.

The next section exercises the convertTemperature function using the test cases and presents the results as a table in the browser console.

Finally, we have the convertTemperature function itself that defines the two conversion calculations, using the arithmetic operations. The function splits the input test case at the degree symbol ° into the temperature unit and scale sections. The temperature unit is then cast from a string into a number and the scale is used to select the calculation required.

In the demonstration code we extract the test data from the sampler into its own JSON file, each arithmetic operation into its own script file, to be loaded by the web worker, the convertTemperature function into the primary web worker plug-in.js file.

Manifest

One significant variation from the scenario described initially in this article and the demonstration code, is the structure of the manifest, which has been simplified.

{ "add": "addition.js", "sub": "subtraction.js", "mul": "multiplication.js", "div": "division.js" } 
Enter fullscreen mode Exit fullscreen mode

A more significant difference between the two examples is that the simple sampler processes the test cases sequentially but the demonstration application processes them asynchronously.

Application (Index.html script)

Sequence diagram between application and plug-ins

The script within the application index.html file starts with loading the testData and preparing the results array. The plug-ins.js file is then loads the web Worker and issues a postMessage to initiate it with a reference to the manifest file. The bulk of the code is contained within the onmessage listener of the web worker.

plugInWorker.onmessage = ({data}) => { if (data === "__LOADED__") { testData.flat().map( (testDatum) => plugInWorker.postMessage(testDatum) ); } else { results.push(data); if (results.length === testData.flat().length) { console.table(testData.flat().map( (testDatum, index) => ({ testcase: testDatum, result: results[index] }) )); plugInWorker.terminate(); } } }; 
Enter fullscreen mode Exit fullscreen mode

The onmessage listener anticipates receiving two types of message:

  1. __LOADED__ indicates the manifest has been processed by the plug-ins worker and all the operation scripts have been 'loaded'.
  2. result is the output from each calculation. When all the calculations have been received they are presented via the browser console.

Plug-ins.js

In addition to the convertTemperature function, the plug-ins.js file contains the instructions to load the manifest file and the scripts containing the arithmetic operations.

let manifestLoaded = false; onmessage = ({ data }) => { if (manifestLoaded) { postMessage(convertTemperature(data)); } else { manifestLoaded = true; import(data, { with: { type: 'json' } }).then( ({ default: manifest }) => { importScripts( ...Object.values(manifest).map( (file) => `./plug-ins/${file}` ) ); postMessage('__LOADED__'); } ); } }; 
Enter fullscreen mode Exit fullscreen mode

The key aspects include the following:

  • The onmessage listener is configured to receive requests from the application.
  • The postMessage instruction is used to send messages back to the application.
  • A dynamic import is used to load the operation scripts defined by the manifest data.
  • The scripts referenced in the manifest are loaded using the importScripts instruction.

Word of Caution

This kind of architecture should only be considered for use in a controlled environment and only where the authors of the plugins can be trusted. Failing to take adequate precautions would have the potential of exposing the user to considerable risk.

Top comments (0)