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.
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.
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.
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; } List of plugins (manifest.json)
[ { "pluginName": "Addition", "pluginFile": "addition.js" } ] 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.
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> 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'); hello-world.js
function helloWorld() { alert('Hello, World! (injection)'); } 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'); hello-world.module.js
function helloWorld() { alert('Hello, World! (import)'); } export default helloWorld; 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.
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 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 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`; } 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" } 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)
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(); } } }; The onmessage listener anticipates receiving two types of message:
-
__LOADED__indicates the manifest has been processed by the plug-ins worker and all the operation scripts have been 'loaded'. -
resultis 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__'); } ); } }; The key aspects include the following:
- The
onmessagelistener is configured to receive requests from the application. - The
postMessageinstruction is used to send messages back to the application. - A dynamic
importis used to load the operation scripts defined by the manifest data. - The scripts referenced in the manifest are loaded using the
importScriptsinstruction.
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)