├── .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
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 |
--------------------------------------------------------------------------------