As the commenters (Pointy, Wayne) say,
It's simply not possible to make a browser block and wait. They just won't do it.
Other than moving to async/await, there is no way to let the async function run to completion and resume execution where you left off. Unless you relinquish the main thread, the async code has nowhere to run.
What might work would be to use web workers. It's not only a nodejs thing, it works in the browser too. Web workers run in a separate thread, so there won't be any blocking. The difficulty lays in communicating with the web worker, because the preferred way to get data in and out of it is to use asynchronous messaging.
Here's how to talk to your web worker synchronously. You'd be using techniques that were often used in the webassembly world, see for example
Communication with the main thread happens by reading and writing data in SharedArrayBuffers which are shared between the main browser thread and the worker, allowing the emulator's main loop to run continuously without ever yielding to the event loop.
- https://blog.persistent.info/2021/08/worker-loop.html
- https://jamesfriend.com.au/basilisk-ii-classic-mac-emulator-in-the-browser#input
This has the following (surpassable) difficulties
The SharedArrayBuffer functionality has been disabled in response to Spectre/Meltdown and later browser releases reenabled it only for sites that set certain CORS headers, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements
In nodejs, one may use Atomics.wait even on the main thread. In the browsers however, it is necessary to use async api or to busywait
Browser does not have the receiveMessageOnPort function (that pont's answer relies on), meaning that it's not possible to do a sync nonblocking receive on a message from a web worker. It's necessary to serialize (JSON.stringify) the data into a SharedArrayBuffer and use this shared memory for communicating.
Sending the data into worker using postMessage did not work in my testing, the messages were only delivered after my code relinquished the main loop, so I was not able to busywait for the message to be sent and answer delivered.
Sample code
This has three part. First, a Python script used to serve the HTML with the required security headers set so that SharedArrayBuffers are available. Second, code for the web worker. Third, the HTML page.
serve.py
#!/usr/bin/env python3
import http.server
import socketserver
import sys
# https://stackoverflow.com/questions/21956683/enable-access-control-on-simple-http-server
class CORSRequestHandler(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
super().end_headers()
def main():
socketserver.TCPServer.allow_reuse_address = True
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
with socketserver.TCPServer(("", PORT), CORSRequestHandler) as httpd:
print("serving at port", PORT)
httpd.serve_forever()
if __name__ == '__main__':
main()
// fibonacci.worker.js
import {encodeToSharedBuffer, decodeFromSharedBuffer} from "./utils.js"
// sample async function that we want to call synchronously
/**
* @param n {number}
* @returns {Promise<number>}
*/
async function fibonacci(n) {
if (n === 1) {
return 1;
} else if (n === 2) {
return 1
} else {
return (await fibonacci(n-2)) + (await fibonacci(n-1));
}
}
// the web worker onmessage handler
// this runs in a separate thread, not in the main thread
/**
* @param event {MessageEvent}
* @returns {Promise<void>}
*/
self.onmessage = async function (event) {
switch (event.data.command) {
case "start": {
const {atomicFlag, sharedData} = event.data;
// the onmessage handler in the page runs main()
self.postMessage("ready for work");
while (true) {
const flagIntView = new Int32Array(atomicFlag)
// wait for flag not be === 0
Atomics.wait(flagIntView, 0, 0);
// load arguments
const jsonArgs = decodeFromSharedBuffer(sharedData);
console.log(jsonArgs);
/**@type number*/
const args = JSON.parse(jsonArgs.trim());
console.log("startingg computation, args=", args);
// run async method
const result = await fibonacci(args)
console.log("finished computation, result=", result);
// store the result
encodeToSharedBuffer(JSON.stringify(result), sharedData);
// set flag
Atomics.store(flagIntView, 0, 0);
// need not actually notify as we busywait in the main thread
Atomics.notify(flagIntView, 0);
}
break;
}
default: {
console.log(event.data);
throw new Error("Unknown command: " + event.data.command);
}
}
};
<html lang="en" xmlns="http://www.w3.org/1999/html">
<head>
<meta charset="UTF-8">
<title></title>
<script type="module">
// https://developer.mozilla.org/en-US/docs/Web/API/Window/crossOriginIsolated
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements
import {decodeFromSharedBuffer, encodeToSharedBuffer} from "./utils.js";
if (!window.crossOriginIsolated) {
throw new Error('crossOriginIsolated is required');
}
const myWorker = new Worker("./fibonacci.worker.js", {name:"fibonacci worker", type: "module"});
/// 0: the page accesses sharedData, 1: the worker accesses shared data
const atomicFlag = new SharedArrayBuffer(4);
const sharedData = new SharedArrayBuffer(2048);
myWorker.postMessage({command: 'start', atomicFlag: atomicFlag, sharedData: sharedData});
function runAsyncFunctionAsSync(arg) {
const flagIntView = new Int32Array(atomicFlag)
// set the arguments
encodeToSharedBuffer(JSON.stringify(arg), sharedData);
// notify the worker
Atomics.store(flagIntView, 0, 1);
Atomics.notify(flagIntView, 0);
// if things go wrong, we would freeze the browser window, so always set a timeout
const timeout = 5000;
// spin-wait for the computation to complete
const then = window.performance.now();
// this is because `Atomics.wait` cannot be called on the main browser thread
// NOTE: If you could flip things around and run the computation in main thread and sync-await it in the worker,
// then things could be made more efficient; when main thead awaits, it has to spin-loop
while (Atomics.load(flagIntView, 0) === 1 && window.performance.now() - then < timeout) {
// noop
}
if (Atomics.load(flagIntView, 0) === 1) {
// waiting failed, submit an error into your log collector
throw new Error("failed to wait for result")
}
const jsonResult = decodeFromSharedBuffer(sharedData)
console.log(jsonResult);
return JSON.parse(jsonResult);
}
// we must wait for the worker to start itself before we can safely run blocking javascript on the
// ui thread, so the code in the page has to be in a callback like this
myWorker.onmessage = main
function main() {
// we can't use `new MessageChannel()` like on nodejs, because on web we don't have `receiveMessageOnPort`
// https://nodejs.org/api/worker_threads.html#workerreceivemessageonportport
// myWorker.postMessage({command: 'compute', args: [42]});
// fibonacci(42) times out, computes for too long
console.log("fibonacci(30) is", runAsyncFunctionAsSync(30));
}
</script>
</head>
<body>
</body>
</html>
// utils.js
// https://stackoverflow.com/questions/72948162/javascript-textdecoder-on-sharedarraybuffer
/**
* @param source {string}
* @param sharedBuffer {SharedArrayBuffer}
*/
export function encodeToSharedBuffer(source, sharedBuffer) {
const encoder = new TextEncoder()
const tempBuffer = new ArrayBuffer(sharedBuffer.byteLength)
const tempView = new Uint8Array(tempBuffer)
const sharedView = new Uint8Array(sharedBuffer)
tempView.byteOffset
encoder.encodeInto(source, tempView)
sharedView.set(tempView)
}
/**
* @param sharedBuffer {SharedArrayBuffer}
* @returns {string}
*/
export function decodeFromSharedBuffer(sharedBuffer) {
const decoder = new TextDecoder()
let sharedView = new Uint8Array(sharedBuffer)
const end = sharedView.indexOf(0)
sharedView = sharedView.subarray(0, end)
const tempBuffer = new ArrayBuffer(sharedView.byteLength)
const tempView = new Uint8Array(tempBuffer)
tempView.set(sharedView)
return decoder.decode(tempBuffer)
}