Using promises to orchestrate Html service polling

In Use promise instead of callback for settimeout I showed how a more structured approach to scheduling events could be achieved through the use of promises rather than timeout callbacks. 

In apps that use HtmlService, such as webapps or add-ons, there is no persistence between what's happening on the Server and the Client so often the only answer is to use setTimeout to regularly poll the server and ask. So that means using google.script.run, which is also asynchronous. If you also have some activity which is asynchronous based on what the server is doing then that's 3 or more asynchronous streams that need to be managed, leaving you in a callback hell. If you have multiple types of polling with different intervals, as for example I have in my DicersSankey SnipChord Snip and Color Arranger add-ons, then it can start to become unmanageable. 

Promises

I've covered introduction to Promises a number of times so I won't repeat that here, but the problem we're trying to solve can be expressed as

wait a bit, then poll and wait for the answer, then process the result, then repeat

This can be nicely expressed as a series of  Promises like this

function polling() {
  wait(sometime)
    .then(function(result) {
       return serverPoll (something);
     })
    .then(function(result) {
       return process(result);
    })
   .then(function (result) {
       polling();
   })
   .catch (function (err) {
      
   });
}

Waiting


In Use promise instead of callback for settimeout I showed how to convert timeout into a promise. Here's the implementation of that from my Provoke namespace. 

  ns.loiter = function (ms, tag) {
    return new Promise(function(resolve, reject) {
      try {
        setTimeout(function() {
          resolve(tag);
        }, ms);
      } catch (err) {
        reject(err);
      }
    });
  };

Now we can start coding our Client app,  by waiting 5 seconds.
Provoke.loiter (5000)
.then (function (result) {
 //... the rest
});

Polling the server


Now that we've waited a bit, we can poll the server. Instead of using google.script.run() here, which will generate a success and failure callback, I'd rather convert that into a Promise too.  In my Provoke namespace, I have the .run method which wraps google.script.run in a Promise pattern like this.
  /**
  * run something asynchronously
  * @param {string} namespace the namespace (null for global)
  * @param {string} method the method or function to call
  * @param {[...]} the args
  * @return {Promise} a promise
  */
  ns.run = function (namespace,method) {
    
    // the args to the server function
    var runArgs = Array.prototype.slice.call(arguments).slice(2);

    if (arguments.length<2) {
      throw new Error ('need at least a namespace and method');
    }

    // this will return a promise
    return new Promise(function ( resolve , reject ) {
      
      google.script.run
    
      .withFailureHandler (function(err) {
        reject (err);
      })
    
      .withSuccessHandler (function(result) {
        resolve (result);
      })
    
      .exposeRun (namespace,method,runArgs); 
    });
    
    
  };

It's generalized so it needs to be able to run a given function as in google.script.run.yourFunction(args,...). Normally, google.script.run needs the function it wants to run to be in the globals space, but I usually avoid this as much as possible to avoid name clashes when reusing code. So to be able to cope with namespaces on the server and variable numbers of arguments, I have a single server side runner function in the global space called exposeRun. It looks like this (but see later in the post for a locked down version that uses whitelists) 
/**
* used to expose memebers of a namespace
* @param {string} namespace name
* @param {method} method name
*/
function exposeRun (namespace, method , argArray ) {
  var func = (namespace ? this[namespace][method] : this[method])
  if (argArray && argArray.length) {
    return func.apply(this,argArray);
  }
  else {
    return func();
  }
}

Let's say then that the function I want to call is in the Server namespace, and is called getData and returns all the data in the currently active sheet - it would look something like this
ns.getData = function () {
  return SpreadsheetApp.getActiveSheet().getDataRange().getValues();
};

Now we can extend the client side code to this

Provoke.loiter (5000)
.then (function (result) {
   return Provoke.run ("Server", "getData");
})
.then (function (result) {
  // .. do something with the data 
})

Processing the result


I'll leave out the definition of processing the data as it depends on your app, but it might be something like modifying it on the client, and writing it back to the Server, so we'll need a setData function back in the server namespace to deal with that, which might look something like this (although you'd want some additional checks about valid data and that the sheet you are writing to is the one you expected to).
ns.setData = function (values) {
  return SpreadsheetApp.getActiveSheet().getRange(1,1,values.length,values[0].length).setValues(values);
};

So now our client side App looks like this

Provoke.loiter (5000)
.then (function (result) {
   return Provoke.run ("Server", "getData");
})
.then (function (result) {
  // .. do something with the data 
  //...
  // write it back to the server
  return Provoke.run ("Server","setData",result);
})
.then (function (result) {
  // we're ready to poll again
});

Recursing

We can recurse the polling function to do it all over again

function polling () {

  Provoke.loiter (5000)
  .then (function (result) {
     return Provoke.run ("Server", "getData");
  })
  .then (function (result) {
    // .. do something with the data 
    //...
    // write it back to the server
    return Provoke.run ("Server","setData",result);
  })
  .then (function (result) {
    // we're ready to poll again
   polling();
  });

}


Catching Errors

Finally, we need to handle any errors. With a series of .then as we have, any errors occurring will cascade through to the end so we only really need one catch. Note that the syntax is actually .catch, but if you are using the Apps Script IDE to develop this in a .gs file, it doesn't like .catch in that spot, so you can get round it by using ['catch'] as in the example.

function polling () {

  Provoke.loiter (5000)
  .then (function (result) {
     return Provoke.run ("Server", "getData");
  })
  .then (function (result) {
    // .. do something with the data 
    //...
    // write it back to the server
    return Provoke.run ("Server","setData",result);
  })
  .then (function (result) {
    // we're ready to poll again
   polling();
  })
  ['catch'] (function (err) {
    // handle the error
  });

}


When you think about how this would look with a series of nested callbacks, I think you can appreciate how this approach simplifies the communication between server and client. Of course almost all the code and patterns are resusable - so your 2nd and subsequent add-ons are a breeze.

Some other tweaks


It's better to only pass data over if its changed, so you can use a checksum as I describe in Watching for server side changes from the client html service, and that way it's easy for the client to let the server know what it already knows about and avoid passing unchanged data back and forth. Switching off polling if the user is not active is another good tweak described in the same post.

Whitelisting

I use a version of exposeRun that has a whitelist of what's allowed to be called from the client, partly to lock it down, but also to help with debugging and documentation. Here's the version from my dicers add-on, which validates that the namespace/method combination  being provoked from the client is one that is allowed to be called.
/**
* used to expose memebers of a namespace
* @param {string} namespace name
* @param {method} method name
*/
function exposeRun (namespace, method , argArray ) {
  
  // I'm using whitelisting to ensure that only namespaces 
  // authorized to be run from the client are enabled
  // why? to avoid mistakes, or potential poking somehow from the dev tools
  var whitelist = [ 
    {namespace:"Server", method:null},
    {namespace:"ServerWatcher", method:null},
    {namespace:"Props", method:[
      "getAll",
      "getRegistration",
      "setDocument",
      "removeDocument",
      "removeUser",
      "setPlan",
      "swtUser"
    ]},
    {namespace:"GasStripeCheckout",method:"getKey"}
  ];
  
  // check allowed
  if (whitelist && !whitelist.some(function(d) {
    return namespace === d.namespace && 
      (!d.method || d.method.some(function(e) { return e===method}));
  })) {
    throw (namespace || "this") + "." + method + " is not whitelisted to be run from the client";
  }
  
  var func = (namespace ? this[namespace][method] : this[method]);
  if (typeof func !== 'function' ) {
    throw (namespace || "this") + "." + method + " should be a function";
  }
  if (argArray && argArray.length) {
    return func.apply(this,argArray);
  }
  else {
    return func();
  }
}

For more like this, see Google Apps Scripts snippets. Why not join our forumfollow the blog or follow me on twitter to ensure you get updates when they are available. 

You want to learn Google Apps Script?

Learning Apps Script, (and transitioning from VBA) are covered comprehensively in my my book, Going Gas - from VBA to Apps script, available All formats are available now from O'Reilly,Amazon and all good bookshops. You can also read a preview on O'Reilly

If you prefer Video style learning I also have two courses available. also published by O'Reilly.
Google Apps Script for Developers and Google Apps Script for Beginners.





Comments