How to await an async call in JavaScript in a synchronous function?
Asked Answered
C

4

26

I recently had to correct security issues in a web-application (that I didn't create). The security problem was, it was using non-http-only cookies. So I had to set the session-cookie http-only, which means you can't read (and set) the cookie's value anymore from javascript. So far so seamingly easy.

The deeper problem was, the web-application used

JSON.parse(readCookie(cookieName)).some_value

on a million places.

So in order to not have to re-write "a million lines of code", I had to create an ajax-endpoint that gave me the http-cookie's content as JSON and rewrite readCookie to use SYNCHRONOUS ajax requests (instead of reading the cookie), because the rest of the horrible code expects readCookie to be synchronous at these million places, because reading a cookie is synchronous.

The problem now is, I get a lot of

Synchronous XMLHttpRequest on the main thread is deprecated because of its detrimental effects to the end user's experience. For more help, check https://xhr.spec.whatwg.org/.

which spams the debug console, let alone the possibility someone decides to remove this functionality.

I am therefore looking into the new ES async/await keywords, to see if that could help somehow in making a asynchronous ajax-request synchronously (i know I have to use wrappers for IE 11).

So far, I read these pages
https://www.twilio.com/blog/2015/10/asyncawait-the-hero-javascript-deserved.html
https://pouchdb.com/2015/03/05/taming-the-async-beast-with-es7.html
https://jakearchibald.com/2014/es7-async-functions/
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*

but it looks like all the new async stuff seems to only cater to the problem of writing asynchronous code easier, not enabling interop between asynchronous and existing synchronous code. Using the information I read, I can now await the result of an asynchronous ajax-call like it was synchronous, but the problem is - await is only allowed in async-methods... Which means even if I can await the result like it was synchronous, the getCookie method would still have to be async, which makes all the stuff appear to be completely pointless (unless your entire code would-be async, which it certainly isn't when you don't start from scratch)...

I can't seem to find any information on how to interop between synchronous and asynchronous code.

For example, in C#, I can call an async-method from a synchronous context with .Result, e.g.

 AsyncContext.RunTask(MyAsyncMethod).Result;

or easier but less deadlock-safe like

MyAsyncMethod(args).Result;

Is there any way to achieve the same in JavaScript ?

It seems to make little sense to spread async around, when the rest of the codebase is synchronous, without any possibility of interop... Is there really still no way to achieve this in JavaScript in 2017 AD ?

I emphasize again:
I know how I can make a synchronous ajax-call, and I know how to use async ajax calls with callbacks and/or promises.
But what I'm unable to figure out is how to synchronize an async-ajax-call (no callback) so it can be used from code that expects to be run synchronously (in "a million places") !

This is what I have tried so far:
(Note that whether I use loadQuote or main, the text "Ron once said" still appears first in the debug-console, which should not be the case if the asynchronous ajax-call had been resolved synchronously)

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />

    <meta http-equiv="cache-control" content="max-age=0" />
    <meta http-equiv="cache-control" content="no-cache" />
    <meta http-equiv="expires" content="0" />
    <meta http-equiv="expires" content="Tue, 01 Jan 1980 1:00:00 GMT" />
    <meta http-equiv="pragma" content="no-cache" />

    <meta charset="utf-8" />
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />

    <meta http-equiv="Content-Language" content="en" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />

    <meta name="google" value="notranslate" />


    <!--
    <meta name="author" content="name" />
    <meta name="description" content="description here" />
    <meta name="keywords" content="keywords,here" />

    <link rel="shortcut icon" href="favicon.ico" type="image/vnd.microsoft.icon" />
    <link rel="stylesheet" href="stylesheet.css" type="text/css" />
    -->

    <title>Title</title>

    <style type="text/css" media="all">
        body
        {
            background-color: #0c70b4;
            color: #546775;
            font: normal 400 18px "PT Sans", sans-serif;
            -webkit-font-smoothing: antialiased;
        }
    </style>


    <script type="text/javascript">
        <!-- 
        // http://localhost:57566/foobar/ajax/json.ashx

        var ajax = {};
        ajax.x = function () {
            if (typeof XMLHttpRequest !== 'undefined') {
                return new XMLHttpRequest();
            }
            var versions = [
                "MSXML2.XmlHttp.6.0",
                "MSXML2.XmlHttp.5.0",
                "MSXML2.XmlHttp.4.0",
                "MSXML2.XmlHttp.3.0",
                "MSXML2.XmlHttp.2.0",
                "Microsoft.XmlHttp"
            ];

            var xhr;
            for (var i = 0; i < versions.length; i++) {
                try {
                    xhr = new ActiveXObject(versions[i]);
                    break;
                } catch (e) {
                }
            }
            return xhr;
        };

        ajax.send = function (url, callback, method, data, async) {
            if (async === undefined) {
                async = true;
            }
            var x = ajax.x();
            x.open(method, url, async);
            x.onreadystatechange = function () {
                if (x.readyState == 4) {
                    callback(x.responseText)
                }
            };
            if (method == 'POST') {
                x.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
            }
            x.send(data)
        };

        ajax.get = function (url, data, callback, async) {
            var query = [];
            for (var key in data) {
                query.push(encodeURIComponent(key) + '=' + encodeURIComponent(data[key]));
            }
            ajax.send(url + (query.length ? '?' + query.join('&') : ''), callback, 'GET', null, async)
        };

        ajax.post = function (url, data, callback, async) {
            var query = [];
            for (var key in data) {
                query.push(encodeURIComponent(key) + '=' + encodeURIComponent(data[key]));
            }
            ajax.send(url, callback, 'POST', query.join('&'), async)
        };


        ///////////



        function testAjaxCall() {
            ajax.get("./ajax/json.ashx", null, function (bError, strMessage, iStatus)
                {
                    console.log("args:", arguments);

                    console.log("Error:", bError);
                    console.log("Message:", strMessage);
                    console.log("Status:", iStatus);
                }
                , true
            );

        }
        -->
    </script>

</head>
<body>

    <script type="text/javascript">

        function getQuote() {
            var quote;

            return new Promise(function (resolve, reject) {

                ajax.get("./ajax/json.ashx", null, function (bError, strMessage, iStatus) {

                    // console.log("args:", arguments);

                    // console.log("Error:", bError);
                    // console.log("Message:", strMessage);
                    // console.log("Status:", iStatus);


                    quote = bError;
                    resolve(quote)

                }, true);


                /*
                request('./ajax/json.ashx', function (error, response, body) {
                    quote = body;

                    resolve(quote);
                });
                */

            });

        }

        async function main() {
            var quote = await getQuote();
            console.log("quote: ", quote);
        }

        function myGetQuote() {
            var quote = async function () { return await getQuote(); };

            console.log("quote: ", quote);

            return quote;
        }

        function spawn(generatorFunc) {
            function continuer(verb, arg) {
                var result;
                try {
                    result = generator[verb](arg);
                } catch (err) {
                    return Promise.reject(err);
                }
                if (result.done) {
                    return result.value;
                } else {
                    return Promise.resolve(result.value).then(onFulfilled, onRejected);
                }
            }
            var generator = generatorFunc();
            var onFulfilled = continuer.bind(continuer, "next");
            var onRejected = continuer.bind(continuer, "throw");
            return onFulfilled();
        }


        function loadQuote() 
        {
            return spawn(function *() {
                try {
                    let story = yield getQuote();

                    console.log("story:", story);
                    // addHtmlToPage(story.heading);
                    // for (let chapter of story.chapterURLs.map(getJSON)) { addHtmlToPage((yield chapter).html); } addTextToPage("All done");
                } catch (err) {
                    //addTextToPage("Argh, broken: " + err.message);
                    console.log("Argh, broken: " + err.message);
                }
                //document.querySelector('.spinner').style.display = 'none';
            });
        }



        function autorun()
        {           
            console.clear();    
            // main();
            // main();
            loadQuote();

            //var quote = myGetQuote();

            // console.log("quote: ", quote);
            console.log('Ron once said,');

        }

        if (document.addEventListener) document.addEventListener("DOMContentLoaded", autorun, false);
        else if (document.attachEvent) document.attachEvent("onreadystatechange", autorun);
        else window.onload = autorun;
    </script>

</body>
</html>
Centrosome answered 1/2, 2017 at 9:51 Comment(6)
Short answer: no there is no way to make async code run synchronous in JS as you know it from C#. Making everything asynchronous is a possible solution.Clink
@Lucero: LOL, pretty much exactly what I posted below, but much, much more succinct. :-)Beauvais
I think it's important to understand that what you are requesting is not a functionality gap in Javascript, it simply doesn't make sense given Javascript's single-threaded (there is only one thread executing user code) asynchronous event-driven execution model. If you could force XHR to be executed synchronously, it would block the entire event loop (and thus the UI) for each XHR. @T.J.Crowder's answer alludes to this, but I think it's useful to make the reason explicit.Withdraw
I know this is five years too late, but one possible solution is to make the asynchronous cookie read XHR once, up front, saving the result in a global variable or cache, and only start the the UI code that depends on it after it completes (using await or then). Then replace the "million" synchronous readCookie calls with a synchronous readCookieFromCache call.Withdraw
Are you only reading and never write?Diaphane
@Inigo: Ingo, no it's not it's too much work. Otherwise i could just change everything to async and I would be done.Centrosome
B
17

but the problem is - await is only allowed in async-methods.

Exactly, and no, there's no workaround for that. JavaScript's run-to-completion semantics demand that synchronous functions complete before any pending asynchronous action (such as the callback to an XHR handler for an async XHR call) can run.

The way JavaScript runs on a given thread is that it processes a queue of jobs1:

  1. Pick up the next pending job
  2. Synchronously execute the code for that job
  3. Only when that job completes go back to Step 1 to pick up the next job

(It's a bit more complicated than that, and there can be different categories of jobs with different priorities depending on the environment, but that's not relevant to this particular question.)

XHR completions and such are jobs that get scheduled in the queue. There is no way to pause a job, run another job from the queue, and then pick up the paused job. async/await provides dramatically simpler syntax for handling asynchronous operations, but they don't change the nature of the job queue.

The only solution I see for your situation is to go async all the way to the top level. This may not be as complicated as you might think (or maybe it will be). In many cases it's adding async in front of function on a lot of functions. However, making those functions asynchronous is likely to have significant knock-on effects (for instance, something that was synchronous in an event handler becoming asynchronous changes the timing of what happens in relation to the UI).

For example, consider this synchronous code:

var btn = document.getElementById("btn");

btn.addEventListener("click", handler, false);

function handler(e) {
  console.log("handler triggered");
  doSomething();
  console.log("handler done");
}

function doSomething() {
  doThis();
  doThat();
  doTheOther();
}

function doThis() {
  console.log("doThis - start & end");
}
function doThat() {
  console.log("doThat - start");
  // do something that takes a while
  var stop = Date.now() + 1000;
  while (Date.now() < stop) {
    // wait
  }
  console.log("doThat - end");
}
function doTheOther() {
  console.log("doThat - start & end");
}
.as-console.wrapper {
  max-height: 80% !important;
}
<input type="button" id="btn" value="Click Me">
<p id="text"></p>

Now we want to make make doThat async (note: will only work on a recent browser supporting async/await, like Chrome; sadly Stack Snippet's Babel config doesn't include them, so we can't use that option):

var btn = document.getElementById("btn");

btn.addEventListener("click", handler, false);

// handler can't be async
function handler(e) {
  console.log("handler triggered");
  doSomething();
  console.log("handler done");
}

// doSomething can be
async function doSomething() {
  doThis();
  await doThat();
  doTheOther();
}

function doThis() {
  console.log("doThis - start & end");
}

// make doThat async
async function doThat() {
  console.log("doThat - start");
  // simulate beginning async operation with setTimeout
  return new Promise(resolve => {
    setTimeout(() => {
      // do something that takes a while
      var stop = Date.now() + 1000;
      while (Date.now() < stop) {
        // wait
      }
      console.log("doThat - end (async)");
    }, 0);
  });
}
function doTheOther() {
  console.log("doThat - start & end");
}
.as-console.wrapper {
  max-height: 80% !important;
}
<input type="button" id="btn" value="Click Me">
<p id="text"></p>

The key thing there is we went async as soon as we could, in doSomething (since handler can't be async). But of course, that changes the timing of the work in relation to the handler. (Of course, we probably should have updated handler to catch errors from the promise `doSomething() returns.)


1 That's the JavaScript spec terminology. The HTML5 spec (which also touches on this) calls them "tasks" instead of "jobs".

Beauvais answered 1/2, 2017 at 10:0 Comment(7)
Yes, the last paragraph was exactly what I was thinking - and the change of timing is exactly why it would likely introduce a lot of errors, which is exactly why I won't do that. And that is before discounting the effort and possible problems arising of having to work around the lack of async support by IE11 all the way down to IE9...Centrosome
@StefanSteiger: That last bit is fairly easily handled with Babel if you enable async/await support. But yeah, this is not going to be a trivial impact on your codebase.Beauvais
"Make everything async" - what if I need to override a 3rd party service method which is "synchronous", but I need to insert a "wait-for-async-call" stuff there which needs to be considered before returning the result?Quahog
@Quahog - (Breaking my strike-related silence.) If that override has to return smth based on the wait-for-async call, then I'm afraid you're probably out of luck. :-| You can't make a synchronous method wait for an asynchronous result within the same JavaScript realm, and if you're working on the main thread, it's not desirable to (since it locks the UI and any other work on the main thread). In some env's there may be cross-realm (or even cross-process) options. In a browser's main thread you only have sync ajax to a server process doing the async work (and that's slowly going away).Beauvais
@T.J.Crowder thank you so much. And I guess I will try always looking at the tasks from the "it-is-not-supposed-to-work-like-that" perspective from now on. In the given case, I just hid from UI something which expects async result. And show it when the data is ready.Quahog
@T.J.Crowder I've been struggling to get my head around promises for months now and finally understand this. Thank you!Leninakan
@T.J.Crowder I've been struggling to get my head around promises for months now and finally understand this. Thank you!Leninakan
T
6

There's is a problem with your approach. First, for part of code to await for async operation to finish, it must be wrapped itself in a async function.

For example:

async function asyncExample () {
    try {
        const response = await myPromise()

        // the code here will wait for the 
        // promise to fullfil
    } catch (error) {
        // the code here will execute if the promise fails
    }
}

function nonAsyncExample () {
    asyncExample () 

    console.log('this will not wait for the async to finish')
    // as it's not wrapped in an async function itself
}

You could try to declare the autorun() function as async, but that may lead to additional complications.

My suggestion, if your JS app has an entry point, it's triggered by an onload event, try to do your ajax call before this point and then store it locally in a variable and query it from there.

For example, if your code looks like:

function init () {
    // perform initialisations here
}

document.addEventListener("DOMContentLoaded", init)

change that to be

document.addEventListener("DOMContentLoaded", function () {
    getAjaxConfig().then(function (response) {
        window.cookieStash = response
        init()
    }
})

and get your data from the cookieStash in the rest of the application. You won't need to wait for anything else.

Thin answered 1/2, 2017 at 10:7 Comment(0)
C
2

Short answer: no there is no way to make async code run synchronous in JS as you know it from C#. Making everything asynchronous is a possible solution.

However, since you also control the server side, I have another suggestion (bit of a hack): send along the required information (cookie content) as metadata of the request, e.g. as HTML meta tag for page requests or HTTP response header for XHR requests, and store it somewhere.

Clink answered 1/2, 2017 at 10:6 Comment(1)
That won't work, because the application also uses pure html pages. But hmmm, I could add a script tag and get it as JSON, that should work everywhere. But then I need to add the script tag at a million places :(Centrosome
R
0

Asking myself the same question, I found this lib: jimmywarting/await-sync

It uses a Web Worker to run the expected Promise while the main thread remains blocked, waiting for the end of the promise.

This has some drawbacks but may avoid moving every functions to async or when it's just not possible (In my case, I use some setters which can't be async...)

Rig answered 28/3 at 17:2 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.