├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── mod.js ├── package.json ├── test ├── bench.js ├── spec.js └── synkit-worker.js └── worker.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # local history 107 | .history 108 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.insertFinalNewline": true, 3 | "files.trimFinalNewlines": true, 4 | "files.trimTrailingWhitespace": true, 5 | "editor.tabSize": 2, 6 | "javascript.preferences.quoteStyle": "single", 7 | "javascript.preferences.importModuleSpecifierEnding": "js", 8 | "javascript.format.insertSpaceAfterConstructor": true, 9 | "javascript.format.insertSpaceAfterCommaDelimiter": true, 10 | "javascript.format.insertSpaceBeforeFunctionParenthesis": true, 11 | "javascript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": true, 12 | "javascript.format.semicolons": "remove", 13 | "javascript.suggest.completeFunctionCalls": true, 14 | "javascript.suggest.completeJSDocs": true, 15 | "js/ts.implicitProjectConfig.checkJs": true, 16 | "editor.defaultFormatter": "vscode.typescript-language-features", 17 | "editor.formatOnSave": true, 18 | "editor.rulers": [ 19 | 80, 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jimmy Wärting 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 | # await-sync JSDoc icon, indicating that this package has built-in type declarations 2 | 3 | > Make an asynchronous function synchronous 4 | 5 | The only cross compatible solution that works fine in Deno, NodeJS and also Web Workers 6 | 7 | The benefit of this package over other `desync` or `synckit`, `make-synchronous` and others 8 | libs is that this only uses web tech like `Worker` and `SharedArrayBuffer` 9 | instead of spawning new processes or using any nodes specific like: `receiveMessageOnPort(port)` or `WorkerData` to transfer the data. therefor this also runs fine in other environment too even 10 | inside Web workers (but requires some [Security steps](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements) 11 | 12 | What more? well, it uses a more enhanced [Worker](https://github.com/jimmywarting/whatwg-worker) inside of NodeJS that make it more 13 | rich by supplementing it with a own `https://` loader so you can import things from cdn 14 | You can also even do `import('blob:uuid')` 15 | 16 | ## Install 17 | 18 | ```sh 19 | npm install await-sync 20 | ``` 21 | 22 | ## Usage 23 | ```js 24 | import { createWorker } from 'await-sync' 25 | 26 | const toSync = createWorker() 27 | 28 | /** 29 | * Exemple of a async function that we want to "make" to sync 30 | * in reallity this function is stringified, moved into a worker thread, an then blocks current thread 31 | * untill it finish. 32 | * 33 | * @param {string} args0 - any structured cloneable type, same as postMessage types supports 34 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm 35 | * @return {Promise, Uint8Array} it must return a Uint8Array so it can be transfered via Atomic operations 36 | */ 37 | function workerCode (url) { 38 | return fetch(url).then(res => res.bytes()) 39 | } 40 | 41 | // will block current thread until the workerCode finnish 42 | const syncAjax = toSync(workerCode) 43 | 44 | // convert it to different types 45 | const uint8 = syncAjax('https://httpbin.org/get') 46 | const blob = new Blob([uint8]) 47 | const arrayBuffer = uint8.buffer 48 | const text = new TextDecoder().decode(uint8) 49 | const json = JSON.parse(text) 50 | 51 | // Exemple of reading a blob synchronous. 52 | const readBlobSync = toSync(blob => blob.bytes()) 53 | const u8 = readBlobSync(new Blob(['abc'])) // Uint8Array([97,98,99]) 54 | ``` 55 | 56 | ## API 57 | 58 | ### toSync(fn, deserializer?) 59 | 60 | Returns a wrapped version of the given async function which executes synchronously. 61 | This means no other code will execute (not even async code) until the given async function is done. 62 | 63 | The given function is executed in a separate Worker thread, so you cannot use any variables/imports from outside the scope of the function. You can pass in arguments to the function. To import dependencies, use `await import(…)` in the function body. 64 | 65 | The argument you supply to the returned wrapped function is send via postMessage 66 | instead of using `Uint8Array` and `Atomics` so they are structural clone'able 67 | 68 | But the response in the given function must always return a `Uint8Array` b/c 69 | there is no other way to transfer the data over in a synchronous other than blocking 70 | the main thread with `Atomic.wait()` 71 | 72 | ## Use a (de)serializer 73 | 74 | If you only using this in NodeJS then there is a grate built in v8 (de)serializer 75 | It supports most values, but not functions and symbols. 76 | 77 | ```js 78 | import { deserialize } from 'node:v8' 79 | 80 | const getJson = awaitSync(async url => { 81 | const { serialize } = await import('node:v8') 82 | const res = await fetch(url) 83 | const json = await res.json() 84 | 85 | return serialize(json) 86 | }, deserialize) 87 | 88 | const json = getJson('https://httpbin.org/get') // JSON 89 | ``` 90 | 91 | For the most part i reccommend just sticking to JSON.parse and stringify and TextDecoder/Encoder. 92 | But if you need to transfer more structural data in other env. too then use something like cbox-x 93 | or other binary representations, here is a list of [alternatives](https://jimmywarting.github.io/3th-party-structured-clone-wpt/) 94 | 95 | 96 | #### For a version without node's specific v8 module you can do the following 97 | 98 | ```js 99 | import { createWorker } from 'await-sync' 100 | 101 | const toSync = createWorker() 102 | 103 | function deserializer (uint8) { 104 | const text = new TextDecoder().decode(uint8) 105 | const json = JSON.parse(text) 106 | return json 107 | } 108 | 109 | const getJson = toSync( 110 | url => fetch(url).then(res => res.bytes()), 111 | deserializer 112 | ) 113 | 114 | const json = getJson('https://httpbin.org/get') // JSON 115 | ``` 116 | 117 | It's essentially the same as doing this, but you would have to call deserialize yourself. 118 | ```js 119 | const getJson = toSync(code) 120 | const json = deserializer(getJson('https://httpbin.org/get')) 121 | ``` 122 | 123 | 124 | ## Misc 125 | 126 | If two separate functions imports the same ESM then it will only be loaded once. 127 | That's b/c they share the same worker thread. The web workers are never terminated. 128 | so the first call may take a bit longer but the 2nd won't 129 | 130 | ```js 131 | const fn1 = toSync(async () => { 132 | globalThis.xyz ??= 0 133 | console.log(globalThis.xyz++) 134 | 135 | const util = await import('./util.js') 136 | return new Uint8Array() 137 | }) 138 | 139 | const fn2 = toSync(async () => { 140 | globalThis.xyz ??= 0 141 | console.log(globalThis.xyz++) 142 | 143 | const util = await import('./util.js') 144 | return new Uint8Array() 145 | }) 146 | 147 | fn1() // Warm up - 527ms (logs: 0) 148 | fn1() // instant - 24ms (logs: 1) 149 | fn2() // instant - 21ms (logs: 2) 150 | ``` 151 | 152 | To terminate the worker, pass in a signal and kill it using a `AbortController` 153 | 154 | ```js 155 | const ctrl = new AbortController() 156 | createWorker(ctrl.signal) 157 | ctrl.abort() 158 | ``` 159 | -------------------------------------------------------------------------------- /mod.js: -------------------------------------------------------------------------------- 1 | /*! to-sync. MIT License. Jimmy Wärting */ 2 | 3 | // Use the native Worker if available, otherwise use the polyfill 4 | const Work = globalThis.Worker || await import('whatwg-worker').then(m => m.default) 5 | 6 | function createWorker (signal) { 7 | // Create a shared buffer to communicate with the worker thread 8 | const ab = new SharedArrayBuffer(8192) 9 | const data = new Uint8Array(ab, 8) 10 | const int32 = new Int32Array(ab) 11 | 12 | // Create the worker thread 13 | const url = new URL('./worker.js', import.meta.url) 14 | const worker = new Work(url, { type: 'module' }) 15 | 16 | // Terminate the worker thread if a signal is aborted 17 | signal?.addEventListener('abort', () => worker.terminate()) 18 | 19 | return function awaitSync (fn, formatter) { 20 | const source = 'export default ' + fn.toString() 21 | const mc = new MessageChannel() 22 | const localPort = mc.port1 23 | const remotePort = mc.port2 24 | worker.postMessage({ port: remotePort, code: source, ab }, [remotePort]) 25 | 26 | return function runSync (...args) { 27 | Atomics.store(int32, 0, 0) 28 | // Send the arguments to the worker thread 29 | localPort.postMessage(args) 30 | // Wait for the worker thread to send the result back 31 | Atomics.wait(int32, 0, 0) 32 | 33 | // Two first values in the shared buffer are the number of bytes left to 34 | // read and the second value is a boolean indicating if the result was 35 | // successful or not. 36 | let bytesLeft = int32[0] 37 | const ok = int32[1] 38 | 39 | if (bytesLeft === -1) { 40 | return new Uint8Array(0) 41 | } 42 | 43 | // Allocate a new Uint8Array to store the result 44 | const result = new Uint8Array(bytesLeft) 45 | let offset = 0 46 | 47 | // Read the result from the shared buffer 48 | while (bytesLeft > 0) { 49 | // Read all the data that is available in the SharedBuffer 50 | const part = data.subarray(0, Math.min(bytesLeft, data.byteLength)) 51 | // Copy the data to the result 52 | result.set(part, offset) 53 | // Update the offset 54 | offset += part.byteLength 55 | // If we have read all the data, break the loop 56 | if (offset === result.byteLength) break 57 | // Notify the worker thread that we are ready to receive more data 58 | Atomics.notify(int32, 0) 59 | // Wait for the worker thread to send more data 60 | Atomics.wait(int32, 0, bytesLeft) 61 | // Update the number of bytes left to read 62 | bytesLeft -= part.byteLength 63 | } 64 | 65 | if (ok) { 66 | return formatter ? formatter(result) : result 67 | } 68 | 69 | const str = new TextDecoder().decode(result) 70 | const err = JSON.parse(str) 71 | const error = new Error(err.message) 72 | error.stack = err.stack 73 | throw error 74 | } 75 | } 76 | } 77 | 78 | export { 79 | createWorker 80 | } 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "await-sync", 3 | "version": "1.0.3", 4 | "description": "Perform async work synchronously using web worker and SharedArrayBuffer", 5 | "main": "mod.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/jimmywarting/await-sync.git" 13 | }, 14 | "files": [ 15 | "./mod.js", 16 | "./worker.js" 17 | ], 18 | "keywords": [ 19 | "deasync", 20 | "make-synchronous", 21 | "sync", 22 | "sync-exec", 23 | "sync-rpc", 24 | "sync-threads", 25 | "synchronize", 26 | "async", 27 | "asyncronous" 28 | ], 29 | "author": "Jimmy Wärting (https://jimmy.warting.se/opensource)", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/jimmywarting/await-sync/issues" 33 | }, 34 | "engines": { 35 | "node": ">=14.16" 36 | }, 37 | "homepage": "https://github.com/jimmywarting/await-sync#readme", 38 | "funding": [ 39 | { 40 | "type": "github", 41 | "url": "https://github.com/sponsors/jimmywarting" 42 | }, 43 | { 44 | "type": "github", 45 | "url": "https://paypal.me/jimmywarting" 46 | } 47 | ], 48 | "dependencies": { 49 | "whatwg-worker": "^1.0.2" 50 | }, 51 | "devDependencies": { 52 | "make-synchronous": "^1.0.0", 53 | "synckit": "^0.8.5", 54 | "tinylet": "^0.1.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /test/bench.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs' 2 | import { Buffer } from 'node:buffer' 3 | import { createWorker } from '../mod.js' 4 | import makeSynchronous from 'make-synchronous' 5 | import { createSyncFn } from 'synckit' 6 | import { redlet } from 'tinylet' 7 | 8 | // the worker path must be absolute 9 | const workerPath = new URL('./synkit-worker.js', import.meta.url).toString().slice(7) 10 | const awaitSync = createSyncFn(workerPath, {}) 11 | 12 | const sin = makeSynchronous(async path => { 13 | const fs = await import('fs/promises') 14 | return fs.readFile(new URL(path)) 15 | }) 16 | 17 | const path = new URL('./bench.js', import.meta.url) + '' 18 | 19 | const jim = createWorker()(async path => { 20 | const fs = await import('fs/promises') 21 | return fs.readFile(new URL(path)) 22 | }) 23 | 24 | // Runs in a worker thread and uses Atomics.wait() to block the current thread. 25 | const redletReader = redlet(async (path) => { 26 | const fs = await import('fs/promises') 27 | return fs.readFile(new URL(path)) 28 | }) 29 | 30 | const control = Buffer.from(readFileSync(new URL(path))).toString() 31 | // console.assert(Buffer.from(awaitSync(path)).toString() === control, 'should return the same data') 32 | // console.assert(Buffer.from(jim(path)).toString() === control, 'should return the same data') 33 | // console.assert(Buffer.from(sin(path)).toString() === control, 'should return the same data') 34 | // console.assert(Buffer.from(redletReader(path)).toString() === control, 'should return the same data') 35 | 36 | let i 37 | 38 | i = 100 39 | console.time('fs.readFileSync') 40 | while (i--) readFileSync(new URL(path)) 41 | console.timeEnd('fs.readFileSync') 42 | 43 | globalThis?.gc() 44 | 45 | i = 100 46 | console.time('redletReader') 47 | while (i--) redletReader(path) 48 | console.timeEnd('redletReader') 49 | 50 | globalThis?.gc() 51 | 52 | i = 100 53 | console.time('synkit') 54 | while (i--) awaitSync(path) 55 | console.timeEnd('synkit') 56 | 57 | globalThis?.gc() 58 | 59 | i = 100 60 | console.time('await-sync') 61 | while (i--) jim(path) 62 | console.timeEnd('await-sync') 63 | 64 | globalThis?.gc() 65 | 66 | i = 100 67 | console.time('make-syncronous') 68 | while (i--) sin(path) 69 | console.timeEnd('make-syncronous') 70 | -------------------------------------------------------------------------------- /test/spec.js: -------------------------------------------------------------------------------- 1 | import { createWorker } from '../mod.js' 2 | 3 | const ctrl = new AbortController() 4 | const awaitSync = createWorker(ctrl.signal) 5 | 6 | const fn = awaitSync(async function (pkg) { 7 | const { default: json } = await import(pkg, { assert: { type: 'json' } }) 8 | const textEncoder = new TextEncoder() 9 | const str = JSON.stringify(json) 10 | return textEncoder.encode(str) 11 | }, r => new TextDecoder().decode(r)) 12 | 13 | const returnsEmptyData = awaitSync(async function () { 14 | return new Uint8Array(0) 15 | }) 16 | 17 | console.assert(returnsEmptyData().byteLength === 0, 'empty byteLength should be 0') 18 | 19 | const pkg = fn(new URL('../package.json', import.meta.url) + '') 20 | ctrl.abort() 21 | const json = JSON.parse(pkg) 22 | 23 | if (json.name === 'await-sync') { 24 | console.log('test completed') 25 | } else { 26 | throw new Error('test failed') 27 | } 28 | -------------------------------------------------------------------------------- /test/synkit-worker.js: -------------------------------------------------------------------------------- 1 | // worker.js 2 | import { runAsWorker } from 'synckit' 3 | 4 | runAsWorker(async (path) => { 5 | const fs = await import('fs/promises') 6 | return fs.readFile(new URL(path)) 7 | }) 8 | -------------------------------------------------------------------------------- /worker.js: -------------------------------------------------------------------------------- 1 | /*! to-sync. MIT License. Jimmy Wärting */ 2 | 3 | const textEncoder = new TextEncoder() 4 | 5 | addEventListener('message', async evt => { 6 | const { port, code, ab } = evt.data 7 | const data = new Uint8Array(ab, 8) 8 | const int32 = new Int32Array(ab, 0, 2) 9 | 10 | /** @param {Uint8Array} buf */ 11 | const write = buf => { 12 | let bytesLeft = buf.byteLength 13 | let offset = 0 14 | 15 | if (bytesLeft === 0) { 16 | int32[0] = -1 17 | Atomics.notify(int32, 0) 18 | } 19 | 20 | while (bytesLeft > 0) { 21 | int32[0] = bytesLeft 22 | const chunkSize = Math.min(bytesLeft, data.byteLength) 23 | data.set(buf.subarray(offset, offset + chunkSize), 0) 24 | Atomics.notify(int32, 0) 25 | if (bytesLeft === chunkSize) break 26 | Atomics.wait(int32, 0, bytesLeft) 27 | bytesLeft -= chunkSize 28 | offset += chunkSize 29 | } 30 | } 31 | 32 | // const blob = new Blob([code], { type: 'text/javascript' }) 33 | // const url = URL.createObjectURL(blob) 34 | const url = "data:text/javascript," + encodeURIComponent(code) 35 | const { default: fn } = await import(url) 36 | 37 | port.onmessage = async function onmessage (evt) { 38 | const args = evt.data 39 | const [u8, ok] = await Promise.resolve(fn(...args)) 40 | .then(r => { 41 | if (!(r instanceof Uint8Array)) { 42 | throw new Error('result must be a Uint8Array, got: ' + typeof r) 43 | } 44 | return [r, 1] 45 | }) 46 | .catch(e => { 47 | const err = JSON.stringify({ 48 | message: e?.message || e, 49 | stack: e?.stack 50 | }) 51 | const r = textEncoder.encode(err) 52 | return [r, 0] 53 | }) 54 | 55 | int32[1] = ok 56 | write(u8) 57 | } 58 | }) 59 | --------------------------------------------------------------------------------