├── eslint.config.js ├── lib ├── indexes.js └── objects.js ├── .c8rc ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── test ├── fixtures │ ├── echo.mjs │ └── failure.mjs ├── objects.test.js └── base.test.js ├── package.json ├── LICENSE ├── README.md ├── everysync.js └── .gitignore /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({}) 4 | -------------------------------------------------------------------------------- /lib/indexes.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports.OFFSET = 64 4 | // * 0: writing from worker, reading from main 5 | module.exports.TO_WORKER = 0 6 | // * 1: writing from main, reading from worker 7 | module.exports.TO_MAIN = 1 8 | -------------------------------------------------------------------------------- /.c8rc: -------------------------------------------------------------------------------- 1 | { 2 | "all": true, 3 | "exclude": [ 4 | "test/**", 5 | "eslint.config.js" 6 | ], 7 | "clean": true, 8 | "check-coverage": true, 9 | "branches": 100, 10 | "lines": 100, 11 | "functions": 100, 12 | "statements": 100 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: standard 10 | versions: 11 | - 16.0.3 12 | -------------------------------------------------------------------------------- /test/fixtures/echo.mjs: -------------------------------------------------------------------------------- 1 | import { workerData } from 'node:worker_threads' 2 | import { wire } from '../../everysync.js' 3 | 4 | wire(workerData.data, { 5 | async echo (arg) { 6 | return arg 7 | }, 8 | }) 9 | 10 | // Keep the event loop alive 11 | setInterval(() => {}, 100000) 12 | -------------------------------------------------------------------------------- /test/fixtures/failure.mjs: -------------------------------------------------------------------------------- 1 | import { workerData } from 'node:worker_threads' 2 | import { wire } from '../../everysync.js' 3 | 4 | wire(workerData.data, { 5 | fail (arg) { 6 | return new Promise((resolve, reject) => { 7 | // nothing to do here, we will fail 8 | }) 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "everysync", 3 | "version": "0.2.2", 4 | "description": "Make all API sync", 5 | "main": "everysync.js", 6 | "scripts": { 7 | "test": "eslint && c8 node --test test/*.test.js" 8 | }, 9 | "author": "Matteo Collina ", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "c8": "^10.1.2", 13 | "eslint": "^9.6.0", 14 | "neostandard": "^0.12.0" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git://github.com/mcollina/everysync.git" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - 'docs/**' 7 | - '*.md' 8 | pull_request: 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [20.x, 22.x] 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - name: Use Node.js 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - name: Install 29 | run: | 30 | npm install 31 | 32 | - name: Run tests 33 | run: | 34 | npm run test 35 | -------------------------------------------------------------------------------- /lib/objects.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { serialize, deserialize } = require('node:v8') 4 | 5 | function read (buffer, byteOffset = 0) { 6 | const view = new DataView(buffer, byteOffset) 7 | const length = view.getUint32(0, true) 8 | const object = deserialize(new Uint8Array(buffer, byteOffset + 4, length)) 9 | return object 10 | } 11 | 12 | function write (buffer, object, byteOffset = 0) { 13 | const data = serialize(object) 14 | 15 | if (buffer.byteLength < data.byteLength + 4 + byteOffset) { 16 | if (!buffer.growable) { 17 | throw new Error('Buffer is not growable') 18 | } 19 | /* c8 ignore next 2 */ 20 | buffer.grow(data.byteLength + 4 + byteOffset) 21 | } 22 | const view = new DataView(buffer, byteOffset) 23 | view.setUint32(0, data.byteLength, true) 24 | new Uint8Array(buffer, byteOffset + 4).set(data) 25 | } 26 | 27 | module.exports.read = read 28 | module.exports.write = write 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Matteo Collina 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 is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | 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 IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # everysync 2 | 3 | Make any API sync with the help of [`node:worker_threads`](https://nodejs.org/api/worker_threads.html) and [`node:fs`](https://nodejs.org/api/worker_threads.html). 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm install everysync 9 | ``` 10 | 11 | ## Expose async APIs from a worker thread 12 | 13 | Caller side: 14 | 15 | ```javascript 16 | import { join } from 'node:path' 17 | import { strictEqual } from 'node:assert' 18 | import { Worker } from 'node:worker_threads' 19 | import { makeSync } from 'everysync' 20 | 21 | const buffer = new SharedArrayBuffer(1024, { 22 | maxByteLength: 64 * 1024 * 1024, 23 | }) 24 | const worker = new Worker(join(import.meta.dirname, 'echo.mjs'), { 25 | workerData: { 26 | data: buffer, 27 | }, 28 | }) 29 | 30 | const api = makeSync(buffer) 31 | 32 | strictEqual(api.echo(42), 42) 33 | 34 | worker.terminate() 35 | ``` 36 | 37 | Worker side (`echo.mjs`): 38 | 39 | ```javascript 40 | import { wire } from 'everysync' 41 | import { workerData } from 'node:worker_threads' 42 | import { setTimeout } from 'node:timers/promises' 43 | 44 | wire(workerData.data, { 45 | async echo (value) { 46 | await setTimeout(1000) 47 | return value 48 | }, 49 | }) 50 | 51 | // Keep the event loop alive 52 | setInterval(() => {}, 100000) 53 | ``` 54 | 55 | ## License 56 | 57 | MIT 58 | -------------------------------------------------------------------------------- /test/objects.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const assert = require('node:assert/strict') 5 | const { read, write } = require('../lib/objects') 6 | 7 | const hasGrowable = typeof SharedArrayBuffer.prototype.grow === 'function' 8 | 9 | test('mirror test', () => { 10 | const obj = { foo: 'bar' } 11 | const buffer = new SharedArrayBuffer(1024) 12 | write(buffer, obj) 13 | const obj2 = read(buffer) 14 | assert.deepEqual(obj, obj2) 15 | }) 16 | 17 | test('mirror test with offset', () => { 18 | const obj = { foo: 'bar' } 19 | const buffer = new SharedArrayBuffer(1024) 20 | write(buffer, obj, 4) 21 | const obj2 = read(buffer, 4) 22 | assert.deepEqual(obj, obj2) 23 | }) 24 | 25 | test('grow a non-growable buffer', () => { 26 | const obj = { foo: 'bar' } 27 | const buffer = new SharedArrayBuffer(10) 28 | assert.throws(() => write(buffer, obj)) 29 | }) 30 | 31 | test('growable', { skip: !hasGrowable }, () => { 32 | const obj = { foo: 'bar' } 33 | const buffer = new SharedArrayBuffer(2, { 34 | maxByteLength: 1024, 35 | }) 36 | write(buffer, obj) 37 | const obj2 = read(buffer) 38 | assert.deepEqual(obj, obj2) 39 | }) 40 | 41 | test('growable with offset', { skip: !hasGrowable }, () => { 42 | const obj = { foo: 'bar' } 43 | const buffer = new SharedArrayBuffer(2, { 44 | maxByteLength: 1024, 45 | }) 46 | write(buffer, obj, 4) 47 | const obj2 = read(buffer, 4) 48 | assert.deepEqual(obj, obj2) 49 | }) 50 | -------------------------------------------------------------------------------- /test/base.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const { join } = require('node:path') 5 | const assert = require('node:assert/strict') 6 | const { Worker } = require('node:worker_threads') 7 | const { makeSync } = require('..') 8 | 9 | test('makeSync and wire', async (t) => { 10 | const buffer = new SharedArrayBuffer(1024, { 11 | maxByteLength: 64 * 1024 * 1024, 12 | }) 13 | const worker = new Worker(join(__dirname, 'fixtures', 'echo.mjs'), { 14 | workerData: { 15 | data: buffer, 16 | }, 17 | }) 18 | 19 | const api = makeSync(buffer) 20 | 21 | t.after(() => { 22 | worker.terminate() 23 | }) 24 | 25 | assert.strictEqual(api.echo(42), 42) 26 | }) 27 | 28 | test('fail to respond', async (t) => { 29 | const buffer = new SharedArrayBuffer(1024, { 30 | maxByteLength: 64 * 1024 * 1024, 31 | }) 32 | const worker = new Worker(join(__dirname, 'fixtures', 'failure.mjs'), { 33 | workerData: { 34 | data: buffer, 35 | }, 36 | }) 37 | 38 | const api = makeSync(buffer, { timeout: 100 }) 39 | 40 | t.after(() => { 41 | worker.terminate() 42 | }) 43 | 44 | assert.throws(() => api.fail(), new Error('The response timed out after 100ms')) 45 | }) 46 | 47 | test('fail to initliaze', async (t) => { 48 | const buffer = new SharedArrayBuffer(1024, { 49 | maxByteLength: 64 * 1024 * 1024, 50 | }) 51 | 52 | assert.throws(() => makeSync(buffer, { timeout: 100 }), new Error('The initialization timed out after 100ms')) 53 | }) 54 | -------------------------------------------------------------------------------- /everysync.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { read, write } = require('./lib/objects') 4 | const { 5 | OFFSET, 6 | TO_MAIN, 7 | TO_WORKER, 8 | } = require('./lib/indexes') 9 | 10 | function makeSync (data, opts = {}) { 11 | const timeout = opts.timeout || 1000 12 | const metaView = new Int32Array(data) 13 | 14 | const res = Atomics.wait(metaView, TO_WORKER, 0, timeout) 15 | Atomics.store(metaView, TO_WORKER, 0) 16 | 17 | if (res === 'ok') { 18 | const obj = read(data, OFFSET) 19 | 20 | const api = {} 21 | for (const key of obj) { 22 | api[key] = (...args) => { 23 | write(data, { key, args }, OFFSET) 24 | Atomics.store(metaView, TO_MAIN, 1) 25 | Atomics.notify(metaView, TO_MAIN, 1) 26 | const res = Atomics.wait(metaView, TO_WORKER, 0, timeout) 27 | Atomics.store(metaView, TO_WORKER, 0) 28 | if (res === 'ok') { 29 | const obj = read(data, OFFSET) 30 | return obj 31 | } else { 32 | throw new Error(`The response timed out after ${timeout}ms`) 33 | } 34 | } 35 | } 36 | 37 | return api 38 | } else { 39 | throw new Error(`The initialization timed out after ${timeout}ms`) 40 | } 41 | } 42 | 43 | async function wire (data, obj) { 44 | write(data, Object.keys(obj), OFFSET) 45 | 46 | const metaView = new Int32Array(data) 47 | 48 | Atomics.store(metaView, TO_WORKER, 1) 49 | Atomics.notify(metaView, TO_WORKER) 50 | 51 | while (true) { 52 | const waitAsync = Atomics.waitAsync(metaView, TO_MAIN, 0) 53 | const res = await waitAsync.value 54 | Atomics.store(metaView, TO_MAIN, 0) 55 | 56 | if (res === 'ok') { 57 | const { key, args } = read(data, OFFSET) 58 | // This is where the magic happens 59 | const result = await obj[key](...args) 60 | write(data, result, OFFSET) 61 | Atomics.store(metaView, TO_WORKER, 1) 62 | Atomics.notify(metaView, TO_WORKER, 1) 63 | } 64 | } 65 | } 66 | 67 | module.exports.makeSync = makeSync 68 | module.exports.wire = wire 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | --------------------------------------------------------------------------------