├── .gitignore ├── src ├── transferable.js ├── references.js ├── ffi.js ├── utils.js ├── worker.js ├── handler.js ├── shared.js └── main.js ├── test ├── todos │ ├── shared-tasks.js │ └── index.html ├── worker.js ├── shared-worker.js └── index.html ├── rollup.config.js ├── LICENSE ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /src/transferable.js: -------------------------------------------------------------------------------- 1 | export default class Transferable { 2 | constructor(data, options) { 3 | this.data = data; 4 | this.options = options; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/references.js: -------------------------------------------------------------------------------- 1 | export const references = new Set; 2 | 3 | export const send = (name, args) => { 4 | for (const wr of references) 5 | wr.deref()?.postMessage([true, name, args]); 6 | }; 7 | -------------------------------------------------------------------------------- /src/ffi.js: -------------------------------------------------------------------------------- 1 | const { assign } = Object; 2 | 3 | export const ffi = {}; 4 | 5 | /** 6 | * @param {object} bindings 7 | * @returns {object} 8 | */ 9 | export const exports = bindings => assign(ffi, bindings); 10 | -------------------------------------------------------------------------------- /test/todos/shared-tasks.js: -------------------------------------------------------------------------------- 1 | import { broadcast, exports } from '../../dist/shared.js'; 2 | 3 | const items = new Set; 4 | 5 | exports({ 6 | add(value) { 7 | items.add(value); 8 | broadcast(...items); 9 | }, 10 | delete(value) { 11 | items.delete(value); 12 | broadcast(...items); 13 | }, 14 | tasks() { 15 | broadcast(...items); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /test/worker.js: -------------------------------------------------------------------------------- 1 | import asTemplateStringsArray from 'https://esm.run/template-strings-array'; 2 | import { broadcast, exports } from '../dist/worker.js'; 3 | 4 | exports({ 5 | multiple: () => 'bindings', 6 | }); 7 | 8 | exports({ 9 | random: () => ({ Worker: Math.random() }), 10 | tag(template, ...values) { 11 | template = asTemplateStringsArray(template); 12 | console.log({ template, values }); 13 | } 14 | }); 15 | 16 | broadcast('worker:connected'); 17 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const { isArray } = Array; 2 | 3 | /** 4 | * @param {Event} event 5 | * @param {(event:Event) => void} callback 6 | * @returns 7 | */ 8 | export const isChannel = (event, callback) => { 9 | const { data, ports } = /** @type {MessageEvent} */(event); 10 | if (data === 'accordant' && isArray(ports) && ports.at(0) instanceof MessagePort) { 11 | event.stopImmediatePropagation(); 12 | event.currentTarget.removeEventListener(event.type, callback); 13 | return true; 14 | } 15 | return false; 16 | }; 17 | 18 | export const withResolvers = () => Promise.withResolvers(); 19 | -------------------------------------------------------------------------------- /src/worker.js: -------------------------------------------------------------------------------- 1 | import '@webreflection/channel/worker'; 2 | 3 | import { isChannel, withResolvers } from './utils.js'; 4 | import { exports, ffi } from './ffi.js'; 5 | import Handler from './handler.js'; 6 | 7 | const { promise, resolve } = withResolvers(); 8 | 9 | addEventListener('channel', function channel(event) { 10 | if (isChannel(event, channel)) { 11 | const [port] = event.ports; 12 | port.addEventListener('message', new Handler(ffi)); 13 | resolve(port); 14 | } 15 | }); 16 | 17 | const broadcast = (...args) => { 18 | promise.then(port => port.postMessage([true, '', args])); 19 | }; 20 | 21 | export { broadcast, exports }; 22 | -------------------------------------------------------------------------------- /src/handler.js: -------------------------------------------------------------------------------- 1 | import Transferable from './transferable.js'; 2 | import { send } from './references.js'; 3 | 4 | const post = ($, id, result, ...rest) => $.postMessage([id, result], ...rest); 5 | 6 | export default class Handler { 7 | #ffi; 8 | constructor(ffi) { this.#ffi = ffi } 9 | async handleEvent({ currentTarget: port, data: [id, name, args] }) { 10 | if (typeof id === 'number') { 11 | try { 12 | const result = await this.#ffi[name](...args); 13 | if (result instanceof Transferable) { 14 | post(port, id, result.data, result.options); 15 | } 16 | else { 17 | post(port, id, result); 18 | } 19 | } 20 | catch (error) { 21 | post(port, id, error); 22 | } 23 | } 24 | else { 25 | send(name, args); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import terser from '@rollup/plugin-terser'; 3 | 4 | const plugins = [ 5 | nodeResolve(), 6 | terser(), 7 | ]; 8 | 9 | if (process.env.NO_MIN) plugins.pop(); 10 | 11 | export default [ 12 | { 13 | plugins, 14 | input: './src/main.js', 15 | output: { 16 | esModule: true, 17 | file: './dist/main.js', 18 | } 19 | }, 20 | { 21 | plugins, 22 | input: './src/transferable.js', 23 | output: { 24 | esModule: true, 25 | file: './dist/transferable.js', 26 | } 27 | }, 28 | { 29 | plugins, 30 | input: './src/shared.js', 31 | output: { 32 | esModule: true, 33 | file: './dist/shared.js', 34 | } 35 | }, 36 | { 37 | plugins, 38 | input: './src/worker.js', 39 | output: { 40 | esModule: true, 41 | file: './dist/worker.js', 42 | } 43 | }, 44 | ]; 45 | -------------------------------------------------------------------------------- /test/todos/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /test/shared-worker.js: -------------------------------------------------------------------------------- 1 | import { broadcast, exports } from '../dist/shared.js'; 2 | import initSQLite from 'https://cdn.jsdelivr.net/npm/@webreflection/sql.js/database.js'; 3 | const SQLite = initSQLite('accordant'); 4 | 5 | const SharedWorker = Math.random(); 6 | let ports = 0; 7 | let db; 8 | 9 | exports({ 10 | random: () => ({ SharedWorker }), 11 | someSQLite: async () => { 12 | if (!db) { 13 | const Database = await SQLite; 14 | db = new Database('test.db'); 15 | } 16 | 17 | db.run('CREATE TABLE IF NOT EXISTS hello (a int, b char)'); 18 | 19 | db.each('SELECT COUNT(*) AS fields FROM hello', null, async row => { 20 | if (!row.fields) { 21 | db.run(`INSERT INTO hello VALUES (0, 'hello')`); 22 | db.run(`INSERT INTO hello VALUES (1, 'world')`); 23 | await db.save(); 24 | } 25 | db.each('SELECT * FROM hello', null, console.log); 26 | }); 27 | } 28 | }); 29 | 30 | addEventListener('port:connected', ({ type }) => { 31 | broadcast(type, ++ports); 32 | }); 33 | 34 | addEventListener('port:disconnected', ({ type }) => { 35 | broadcast(type, --ports); 36 | }); 37 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 20 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2024-today, Andrea Giammarchi, @WebReflection 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the “Software”), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included 13 | in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 21 | IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/shared.js: -------------------------------------------------------------------------------- 1 | import '@webreflection/channel/shared'; 2 | 3 | import { isChannel, withResolvers } from './utils.js'; 4 | import { references, send } from './references.js'; 5 | import { exports, ffi } from './ffi.js'; 6 | import Handler from './handler.js'; 7 | 8 | const fr = new FinalizationRegistry(wr => { 9 | references.delete(wr); 10 | notify(false); 11 | }); 12 | 13 | const { promise, resolve } = withResolvers(); 14 | 15 | const notify = connected => { 16 | const type = `port:${connected ? 'connected' : 'disconnected'}`; 17 | dispatchEvent(new Event(type)); 18 | } 19 | 20 | addEventListener('connect', ({ ports }) => { 21 | for (const port of ports) { 22 | port.addEventListener('channel', function channel(event) { 23 | if (isChannel(event, channel)) { 24 | const [port] = event.ports; 25 | const wr = new WeakRef(port); 26 | port.addEventListener('message', new Handler(ffi)); 27 | fr.register(port, wr); 28 | references.add(wr); 29 | notify(true); 30 | resolve(); 31 | } 32 | }); 33 | } 34 | }); 35 | 36 | const broadcast = (...args) => { 37 | promise.then(() => send('', args)); 38 | }; 39 | 40 | export { broadcast, exports }; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "accordant", 3 | "version": "0.3.4", 4 | "keywords": [ 5 | "Worker", 6 | "SharedWorker", 7 | "async", 8 | "bindings" 9 | ], 10 | "type": "module", 11 | "scripts": { 12 | "build": "rollup --config rollup.config.js" 13 | }, 14 | "files": [ 15 | "dist/*", 16 | "src/*", 17 | "README.md", 18 | "LICENSE" 19 | ], 20 | "exports": { 21 | "./main": "./src/main.js", 22 | "./shared-worker": "./src/shared.js", 23 | "./transferable": "./src/transferable.js", 24 | "./worker": "./src/worker.js", 25 | "./package.json": "./package.json" 26 | }, 27 | "author": "Andrea Giammarchi", 28 | "license": "MIT", 29 | "description": "One way shared/worker async bindings", 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/WebReflection/accordant.git" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/WebReflection/accordant/issues" 36 | }, 37 | "homepage": "https://github.com/WebReflection/accordant#readme", 38 | "dependencies": { 39 | "@webreflection/channel": "^0.2.0" 40 | }, 41 | "devDependencies": { 42 | "@rollup/plugin-node-resolve": "^16.0.0", 43 | "@rollup/plugin-terser": "^0.4.4", 44 | "rollup": "^4.30.1" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import * as channel from '@webreflection/channel/main'; 2 | 3 | import { withResolvers } from './utils.js'; 4 | 5 | export const broadcast = Symbol(); 6 | const references = new WeakMap; 7 | 8 | const proxyHandler = { 9 | get(channel, name) { 10 | return async (...args) => { 11 | if (name === broadcast) 12 | channel.postMessage([true, this.uuid, args]); 13 | else { 14 | const id = this.id++; 15 | const { promise, resolve, reject } = withResolvers(); 16 | this.ids.set(id, r => (r instanceof Error ? reject : resolve)(r)); 17 | channel.postMessage([id, name, args]); 18 | return promise; 19 | } 20 | }; 21 | }, 22 | has: () => false, 23 | ownKeys: () => [], 24 | }; 25 | 26 | const createProxy = (port, broadcast) => { 27 | const ids = new Map; 28 | const uuid = crypto.randomUUID(); 29 | const channel = port.createChannel('accordant'); 30 | channel.addEventListener('message', ({ data }) => { 31 | const [id, result] = data; 32 | if (typeof id === 'number') { 33 | ids.get(id)(result); 34 | delete ids.get(id); 35 | } 36 | else if (result !== uuid) { 37 | broadcast?.(...data.at(2)); 38 | } 39 | }); 40 | return new Proxy(channel, { ...proxyHandler, uuid, ids, id: 0 }); 41 | }; 42 | 43 | const create = (Class, url, options) => { 44 | const w = new Class(url, options); 45 | const port = Class === channel.Worker ? w : w.port; 46 | const proxy = createProxy(port, options?.[broadcast]); 47 | references.set(proxy, w); 48 | return proxy; 49 | }; 50 | 51 | export const proxied = proxy => references.get(proxy); 52 | 53 | export function SharedWorker(url, options) { 54 | return create(channel.SharedWorker, url, options); 55 | } 56 | 57 | export function Worker(url, options) { 58 | return create(channel.Worker, url, options); 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # accordant 2 | 3 | One way shared/worker async bindings to simply export bindings, as callbacks, from a *Worker* or a *SharedWorker*'s port. 4 | 5 | ```html 6 | 7 | 8 | 9 | 10 | 11 | 37 | 38 | 39 | ``` 40 | 41 | ```js 42 | // shared-worker.js - multiple ports 43 | import { broadcast, exports } from 'accordant/shared-worker'; 44 | 45 | const sameValue = Math.random(); 46 | 47 | exports({ 48 | random: () => ({ SharedWorker: sameValue }), 49 | }); 50 | 51 | let ports = 0; 52 | 53 | // using the broadcast utility to notify all ports 54 | addEventListener('port:connected', () => { 55 | broadcast('connected ports', ++ports); 56 | }); 57 | 58 | addEventListener('port:disconnected', () => { 59 | broadcast('disconnected port, now there are', --ports, 'ports'); 60 | }); 61 | ``` 62 | 63 | ```js 64 | // worker.js - single "port" 65 | import { broadcast, exports } from 'accordant/worker'; 66 | 67 | exports({ 68 | random: () => ({ Worker: Math.random() }), 69 | }); 70 | 71 | // just invoke the broadcast symbol option 72 | broadcast('current', 'worker'); 73 | ``` 74 | 75 | ## broadcast 76 | 77 | This module offers the current possibilities: 78 | 79 | * on the **main thread**, it is possible to import the `broadcast` **symbol** to help avoiding conflicts with both exported functions (because *symbols* cannot survive a *postMessage* dance) and *SharedWorker* or *Worker* options (future proof, no name clashing). This function will be triggered when the counter *SharedWorker* or *Worker* code decides, arbitrarily, to reflect that invoke on each *main* thread/port, passing along any serializable argument 80 | * on the **SharedWorker** or **Worker** thread, it is possible to import the `broadcast` **function**, so that a call such as `broadcast(...args)` within the *worker* context will invoke, if defined, the *main thread* callback optionally passed during instantiation. In here it is a function because polluting the global worker context with a symbol didn't feel like the right thing to do 81 | 82 | ```js 83 | // main thread 84 | const sw = new SharedWorker('./shared-worker.js', { 85 | [broadcast](...args) { 86 | // invoked when shared-worker.js calls broadcast(...args) 87 | } 88 | }); 89 | const w = new Worker('./worker.js', { 90 | [broadcast](...args) { 91 | // invoked when worker.js calls broadcast(...args) 92 | } 93 | }); 94 | ``` 95 | 96 | Still on the **main thread**, it is also possible to `sw[broadcast](...args)` so that all other ports still listening or available on the *Shared Worker* side of affairs will receive those serialized `args`. 97 | 98 | Please note that for feature parity it is also possible to `w[broadcast](...args)` but this does practically nothing because a worker cannot have multiple ports attached so it will silently send data to nothing but it allow code to be portable across platforms and browsers' versions. 99 | --------------------------------------------------------------------------------