Social Media Photo by bady abbas on Unsplash
An Atomics based Proxy to simplify, and synchronize, Worker related tasks.
Following the description of all different imports to use either on the main or the worker thread.
This is the import that provides the ability to expose main thread's callbacks to the worker thread and to also await callbacks exposed via the worker code.
import coincident from 'coincident/main';
const {
// the Worker to be used (this extends the global one)
Worker,
// a boolean indicating if sabayon is being used
polyfill,
// a utility to transfer values directly via `postMessage`
// (...args: Transferable[]) => Transferable[]
transfer,
} = coincident({
// a utility to parse text, default: JSON.parse
parse: JSON.parse,
// a utility to stringify values, default: JSON.stringify
stringify: JSON.stringify,
// an optional utility to transform values (FFI / Proxy related)
transform: value => value,
});
The Worker
class returned by coincident()
has these features:
- it always starts a Worker as
{ type: "module" }
( mostly because the worker needs toawait coincident()
on bootstrap ) - it optionally accepts a
{ serviceWorker: "../sw.js" }
to help sabayon falling back to synchronous behavior, which is mandatory to use any DOM orwindow
related functionality - it provides to each instance a
proxy
reference where utilities, as callbacks, can be assigned or asynchronously awaited if exposed within worker's code
const { proxy } = new Worker('./worker.js');
// can be invoked from the worker
proxy.location = () => location.href;
// exposed via worker code
await proxy.compute();
This is the import that provides the ability to expose worker thread's callbacks to the main thread and to also directly invoke callbacks exposed via the main proxied reference.
import coincident from 'coincident/worker';
const {
// the counter-part of the main worker.proxy reference
proxy,
// a boolean indicating if sabayon is being used
polyfill,
// a boolean indicating if it's possible to do synchronous operations
sync,
// a utility to transfer values directly via `postMessage`
// (...args: Transferable[]) => Transferable[]
transfer,
} = await coincident({
// a utility to parse text, default: JSON.parse
parse: JSON.parse,
// a utility to stringify values, default: JSON.stringify
stringify: JSON.stringify,
// an optional utility to transform values (FFI / Proxy related)
transform: value => value,
// an optional interrupt reference that is used via `Atomic.wait(sb)`
interrupt: { handler() {}, timeout: 42 },
});
// exposed to the main thread
proxy.compute = async () => {
// super expensive task ...
return 'result';
};
// consumed from the main thread
// synchronous if COI is enabled or
// the Service Worker was passed
console.log(proxy.location());
These exports and their coincident/dist/...
pre-optimized counter-parts allow coincident to drive, from a Worker the main thread and operate directly on it.
When the worker code expects the main window
reference, this import is needed to allow just that.
import coincident from 'coincident/window/main';
// ^^^^^^
const { Worker, polyfill, transfer } = coincident();
The signature, on the main thread, is identical.
On the worker side, this import is also identical to the non-window variant but it's returned namespace, after bootstrap, contains two extra utilities:
import coincident from 'coincident/window/worker';
// ^^^^^^
const {
proxy, polyfill, sync, transfer,
// it's a synchronous, Atomic.wait based, Proxy
// to the actual globalThis reference on the main
window,
// it's an introspection helper that returns `true`
// only when a reference points at the main thread
// (value: any) => boolean
isWindowProxy,
} = await coincident();
// direct synchronous access to the main `window`
console.log(window.location.href);
window.document.body.textContent = 'Hello World π';
These exports and their coincident/dist/...
pre-optimized counter-parts allow coincident to drive, from a Worker both the main thread and operate directly on the running server too.
This feature exists mostly to enable Kiosk or IoT related projects and it should not be publicly available as any malicious worker code could fully take over the server or harm the service.
This is what node or bun or others should import to instrument connected WebSockets.
import coincident from 'coincident/server';
// Bun example
serve({
port,
fetch,
// here coincident options should have
// a "truthy" bun π°
websocket: coincident({ bun: true })
});
// NodeJS ⬑ or any other with `ws` module as example
import { WebSocketServer } from 'ws';
const server = ...;
coincident({
// the `wss` property must be there
wss: new WebSocketServer({ server })
});
The coincident
utility here simply instruments every connected WebSocket to react on message
and close
events.
When the worker code expects both the main window
and the server
references, this import is needed to allow just that.
import coincident from 'coincident/server/main';
// ^^^^^^
const { Worker, polyfill, transfer } = coincident({
ws: 'ws://localhotst:8080/'
// ^^^^^^^^^^^^^^^^^^^^^
});
The signature, on the main thread, is identical except the WebSocket url must be provided during initialization.
On the worker side, this import is also identical to the window variant but it's returned namespace, after bootstrap, contains two extra utilities:
import coincident from 'coincident/server/worker';
// ^^^^^^
const {
proxy, polyfill, sync, transfer,
window, isWindowProxy,
// it's a synchronous, Atomic.wait based, Proxy
// to the actual globalThis reference on the server
server,
// it's an introspection helper that returns `true`
// only when a reference points at the server
// (value: any) => boolean
isServerProxy,
} = await coincident();
// direct synchronous access to the main `server`
server.console.log('Hello World π');
// example of module import
const os = await server.import('os');
console.log(os.platform());
A note about performance
Every single property retrieved via the window
reference is a whole worker β main roundtrip and this is inevitable. There is no "smart caching" ability backed in the project, because everytrhing could be suddenly different at any point in time due side effects that both the worker, or the main thread, could have around previously retrieved references.
Especially when SharedArrayBuffer is polyfilled, and the serviceWorker
provided as option, an average PC would perform up to ~1000 roundtrips per second. That seems like a lot but operations can easily pile up and make the program feel unnecessary slower than it could be (if run on the main thread directly, as comparison).
When native SharedArrayBuffer is enabled though, an average PC would be able to do ~50000 (50x) roundtrips per second .... and yet that could also easily degrade with more complex logic involved.
An easy way to prevent repeated roundtrips, when we already assume a reference will not change by any mean over time, we can take over that "smart caching" explicit operation:
const { window } = await coincident();
const { document } = window;
const { head, body } = document;
// any time we need to change the content
body.textContent = 'Hello World π';
Please note that because those references won't likely ever change on the main thread, there are also no memory leaks hazard, and that's true with every other reference that might live forefer on the main thread.
This module allows different worlds to expose utilities that can be invoked elsewhere and there are two ways this can work:
Atomics.wait
is usable, from a worker, and it will be preferred overAtomics.waitAsync
for the simple reason that it unlocks much more than trivial async exchanges between the two worlds. In this case, if the worker is invoking a foreign exposed utility, it will be fully unresponsive until that utility returned a value and there's no possible workaround. When this happens, the module understands that the requested utility comes from a worker that is paused until such invoke returns, and if this invoke relies on a synchronous worker utility there won't be any chance to complete that request: the worker is stuck and the main can't use it until is not stuck anymore. In this case, an error is thrown with details around which worker utility was invoked while the main utility was executing, and the program won't just block itself forever. This is the most meaningful and reasonable deadlock case tothrow
errors unconditionally ... but ...- if this module runs without
Atomics.wait
ability, meaning no COI headers are enabled and noserviceWorker
fallback has been used, it is possible for a main exported utility to query a worker exported utility one once executed, assuming there is no recursion in doing so (i.e. the worker callsmain()
that internally callsworker()
which in turns callsmain()
again). These cases are rather infinite loops/recursions than deadlocks but if you are sure your main utility is invoking something in the worker that won't cause such infinite recursion, no deadlock error would be shown, as that would not be the case, strictly speaking, but also recursions won't be tracked so ... be careful with your logic!
As rule of thumb, do not ever invoke other world utilities while one of your exported utility is executing, so that code will be guaranteed to work in both Atomics.wait
and Atomics.waitAsync
scenarios without ever worrying about future deadlock, once all headers are available or the serviceWorker
helper will be used.
It is, however, always possible to execute foreign utilities on the next tick, micro-task, timeout, idle state or listener, so that if a main exposed utility needs to invoke a worker utility right after, there are ways to do that.
Unfortunately not enabled by default on the Web, the SharedArrayBuffer primitive requires either special headers permissions to be trusted or a polyfill that can always enable the async abilities of the Atomics specifications and eventually grant the sync abilities too, as long as a ServiceWorker able to handle those requests is installed.
This primitive is needed to enable notifications about data cross realms, notifications that are expected to be sync, in the best case scenario, or async as least possible fallback.
This is the preferred way to use this module or any module depending on it, meaning all headers to enable SAB are in place. To do so:
- be sure your server is providing those headers as explained in MDN
- bootstrap a local server that provides such headers for local tests:
npx mini-coi ./
is all you need to enable these headers locally, but the utility doesn't do much more than serving files with those headers enabled - use
<script src="./mini-coi.js"></script>
on top of your<head>
node in your HTML templates to use automatically a ServiceWorker that force-enable those headers for any request made frm any client. This woks on GitHub pages too, and every other static files handler for local projects - use the ServiceWorker logic enabled out of the box by passing the file
npx sabayon ./public/sw.js
to Worker constructors, so that such SW can be used to polyfill the sync case - provide your own ServiceWorker file whenever a Worker is created, out of the
{ serviceWorker: '../sw.js' }
extra option, as long as it imports utilities from sabayon, as explained in its ServiceWorker related details
The latter 2 points will inevitably fallback to a polyfilled version of the native possible performance but it should be good enough to enable your logic around workers invoking, or reaching, synchronous main thread related tasks.
This module by default does fallback to a SAB polyfill, meaning async notification of any buffer are still granted to be executed or succeed, thanks to sabayon underlying module.
This scenario is ideal when:
- no special headers are allowed in your project
- no Serviceworker is allowed in your project
- you only need to await asynchronous utilities from a Worker, never the other way around
- you never need to reach, or execute, code on the main thread, from a worker
As long as these enabled use cases are clear, here the caveats:
- your worker can't ever reach directly anything coming from the main thread
- your worker doesn't ever receive a main thread reference as an argument
If all of this is clear, it's possible to use coincident module as bridge between worker exported features / utilities consumed asynchronously by the main thread any time it needs to.
This still unlocks tons of use cases out there, but it's definitively a constrained and limited experience.