WebReflection / coincident

An Atomics based Proxy to simplify, and synchronize, Worker related tasks.
MIT License
201 stars 3 forks source link

coincident

Social Media Photo by bady abbas on Unsplash

An Atomics based Proxy to simplify, and synchronize, Worker related tasks.

API

Following the description of all different imports to use either on the main or the worker thread.

coincident/main

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,
});

coincident/main - Worker class

The Worker class returned by coincident() has these features:

const { proxy } = new Worker('./worker.js');

// can be invoked from the worker
proxy.location = () => location.href;

// exposed via worker code
await proxy.compute();

coincident/worker

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());

Window

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.

coincident/window/main

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.

coincident/window/worker

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 πŸ‘‹';

Server

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.


⚠️ WARNING

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.


coincident/server

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.

coincident/server/main

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.

coincident/server/worker

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: ```js 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.

About πŸ’€πŸ”’ Deadlock Message

This module allows different worlds to expose utilities that can be invoked elsewhere and there are two ways this can work:

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.

About SharedArrayBuffer

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.

Enable both sync & async SharedArrayBuffer features

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:

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.

Enable only async SharedArrayBuffer features

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:

As long as these enabled use cases are clear, here the caveats:

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.