Search code examples
javascriptajaxasynchronousasync-awaitsynchronize

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


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>

Solution

  • 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".