From dfbf1ced5ed7e2b1e15ca00834445ea25e1f74c3 Mon Sep 17 00:00:00 2001 From: Sam Hemelryk Date: Wed, 19 Aug 2020 17:10:30 +1200 Subject: [PATCH] MDL-68896 mod_scorm: improved sendBeacon use when unloading --- mod/scorm/module.js | 35 ++++++++- mod/scorm/request.js | 176 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 193 insertions(+), 18 deletions(-) diff --git a/mod/scorm/module.js b/mod/scorm/module.js index 0fd411a4361..c54a19f2595 100644 --- a/mod/scorm/module.js +++ b/mod/scorm/module.js @@ -162,6 +162,12 @@ M.mod_scorm.init = function(Y, nav_display, navposition_left, navposition_top, h if (!window_name && node.title != null) { obj.setAttribute('src', url_prefix + node.title); } + // Attach unload observers to the iframe. The scorm package may be observing these unload events + // and trying to save progress when they occur. We need to ensure we use the Beacon API in those + // situations. + if (typeof mod_scorm_monitorForBeaconRequirement !== 'undefined') { + mod_scorm_monitorForBeaconRequirement(obj); + } if (window_name) { var mine = window.open('','','width=1,height=1,left=0,top=0,scrollbars=no'); if(! mine) { @@ -308,7 +314,12 @@ M.mod_scorm.init = function(Y, nav_display, navposition_left, navposition_top, h }; - // Handle AJAX Request + /** + * @deprecated as it is now unused. + * @param {string} url + * @param {string} datastring + * @returns {string|*|boolean} + */ var scorm_ajax_request = function(url, datastring) { var myRequest = NewHttpReq(); var result = DoRequest(myRequest, url + datastring); @@ -454,13 +465,30 @@ M.mod_scorm.init = function(Y, nav_display, navposition_left, navposition_top, h return null; }; + /** + * Sends a request to the sequencing handler script on the server. + * @param {string} datastring + * @returns {string|boolean|*} + */ + var scorm_dorequest_sequencing = function(datastring) { + var myRequest = NewHttpReq(); + var result = DoRequest( + myRequest, + M.cfg.wwwroot + '/mod/scorm/datamodels/sequencinghandler.php?' + datastring, + '', + false + ); + return result; + }; + // Launch prev sco var scorm_launch_prev_sco = function() { var result = null; if (scoes_nav[launch_sco].flow === 1) { var datastring = scoes_nav[launch_sco].url + '&function=scorm_seq_flow&request=backward'; - result = scorm_ajax_request(M.cfg.wwwroot + '/mod/scorm/datamodels/sequencinghandler.php?', datastring); + result = scorm_dorequest_sequencing(datastring); + // Check the scorm_ajax_result, it may be false. if (result === false) { // Either the outcome was a failure, or we are unloading and simply just don't know // what the outcome actually was. @@ -498,8 +526,9 @@ M.mod_scorm.init = function(Y, nav_display, navposition_left, navposition_top, h var result = null; if (scoes_nav[launch_sco].flow === 1) { var datastring = scoes_nav[launch_sco].url + '&function=scorm_seq_flow&request=forward'; - result = scorm_ajax_request(M.cfg.wwwroot + '/mod/scorm/datamodels/sequencinghandler.php?', datastring); + result = scorm_dorequest_sequencing(datastring); + // Check the scorm_ajax_result, it may be false. if (result === false) { // Either the outcome was a failure, or we are unloading and simply just don't know // what the outcome actually was. diff --git a/mod/scorm/request.js b/mod/scorm/request.js index 4bea10b9c20..21a1ce0a81d 100644 --- a/mod/scorm/request.js +++ b/mod/scorm/request.js @@ -35,10 +35,57 @@ function NewHttpReq() { return httpReq; } -function DoRequest(httpReq,url,param) { +/** + * + * @param {XMLHttpRequest} httpReq + * @param {string} url + * @param {string} param + * @param {boolean} allowBeaconAPI Should the BeaconAPI be used if required? Defaults to true + * If True, and we can use the Beacon API and are should use the beacon API then we will. + * If False, we will not use the Beacon API, even if we expect a synchronous XHR request to fail. + * @returns {string|boolean|*} + * @constructor + */ +function DoRequest(httpReq, url, param, allowBeaconAPI) { - // If we are unloading, and we can use sendBeacon then do that, Chrome does not permit synchronous XHR requests on unload. - if (window.mod_scorm_is_window_closing && navigator && navigator.sendBeacon && FormData) { + // Default allowBeaconAPI to true. This argument was added to the function late. + if (typeof allowBeaconAPI === 'undefined') { + allowBeaconAPI = true; + } + + /** + * Returns true if we are able to use the Beacon API in this browser. + * @returns boolean + */ + var canUseBeaconAPI = function() { + return (allowBeaconAPI && navigator && navigator.sendBeacon && FormData); + }; + + /** + * Returns true if we should use the Beacon API. + * We don't use the Beacon API unless we have to as it stiffles our ability to return data on the request. + * @returns {boolean} + */ + var useBeaconAPI = function() { + if (typeof window.mod_scorm_useBeaconAPI === 'undefined' || window.mod_scorm_useBeaconAPI === false) { + // Last ditch effort, the SCORM package may have introduced its own listeners before our listeners. + // This is OLD API, window.event is not reliable and is not recommended API. + // https://developer.mozilla.org/en-US/docs/Web/API/Window/event + if (window.event && ['beforeunload', 'unload', 'pagehide'].indexOf(window.event.type)) { + window.mod_scorm_useBeaconAPI = true; + } + } + return (window.mod_scorm_useBeaconAPI && canUseBeaconAPI()); + }; + + /** + * Uses the Beacon API to communicate this request to the server. + * This function always returns a successful result, because we don't get the actual result, the page doesn't wait for it. + * @param {string} url + * @param {string} param + * @returns {string} + */ + var useSendBeacon = function(url, param) { // Ok, old API alert, the param is a URI encoded string. We need to split it and convert it to a supported format. // I've chosen FormData and FormData.append as they are compatible with our supported browsers: // - https://developer.mozilla.org/en-US/docs/Web/API/FormData/FormData @@ -59,10 +106,31 @@ function DoRequest(httpReq,url,param) { // We'll also inform it that we are unloading, potentially useful in the future. formData.append('unloading', '1'); + // We're going to add a token to the URL that will identify this request as going to the beacon API. + // In the future this would allow our server side scripts to respond differently when the beacon API + // is being used, as the response will be discarded. + if (url.indexOf('?') === -1) { + // First param + url += '?api=beacon'; + } else { + url += '&api=beacon'; + } + // The results is true or false, we don't get the response from the server. Make it look like it was a success. - navigator.sendBeacon(url, formData); + var outcome = navigator.sendBeacon(url, formData); + if (!outcome) { + if (console && console.log) { + console.log('mod_scorm: Failed to queue navigator.sendBeacon request'); + } + return "false\n101"; + } // This is what a success looks like when it comes back from the server. return "true\n0"; + }; + + // If we are unloading, and we can use sendBeacon then do that, Chrome does not permit synchronous XHR requests on unload. + if (useBeaconAPI()) { + return useSendBeacon(url, param); } // httpReq.open (Method("get","post"), URL(string), Asyncronous(true,false)) @@ -72,6 +140,20 @@ function DoRequest(httpReq,url,param) { try { httpReq.send(param); } catch (e) { + if (console && console.log) { + // This may be frivolous as during a shutdown the console log will most likely be lost. But it may help someone. + var message = 'XHR request from mod_scorm::DoRequest failed'; + if (canUseBeaconAPI()) { + message += '; attempting to use Beacon API.'; + } + console.log(message); + } + // The HTTP request failed. We don't know why, but as a last ditch effort, in case we are unloading and haven't detected it + // we will attempt to send the request one more time using the Beacon API. This will result in a successful result regardless + // of the actual outcome. + if (canUseBeaconAPI()) { + return useSendBeacon(url, param); + } return false; } if (httpReq.status == 200) { @@ -89,6 +171,12 @@ function popupwin(content) { op.document.close(); } +/** + * Global variable to track whether we should use the Beacon API instead of synchronous XHR. + * This gets set to true in situations where we expect synchronoush XHR requests to fail. + */ +window.mod_scorm_useBeaconAPI = false; + /** * We wire up a small marker for the unload events triggered when the user is navigating away or closing the tab. * This is done because Chrome does not allow synchronous XHR requests on the following unload events: @@ -97,15 +185,73 @@ function popupwin(content) { * - pagehide * - visibilitychange */ -(function() { - // Set up a global var. Sorry about this, old code ... old ways. - window.mod_scorm_is_window_closing = false; - var toggle = function() { - window.mod_scorm_is_window_closing = true; +function mod_scorm_monitorForBeaconRequirement(target) { + + if (typeof target.mod_scorm_monitoring_for_beacon_requirement !== 'undefined') { + // We're already observing unload events on this target. + console.log('mod_scorm: unload event handlers already attached'); + return; + } + target.mod_scorm_monitoring_for_beacon_requirement = true; + + // The navigator.sendBeacon API is available in all browsers EXCEPT Internet Explorer (IE) + // Internet explorer should never get past this check. + if (!navigator || !navigator.sendBeacon) { + // We can't use the BeaconAPI. There is no point in proceeding to observe unload events. + // This is done after adding the flag to target, and establishing the window variable. + return; + } + + /** + * Turns on the use of the Beacon API. + */ + var toggleOn = function() { + window.mod_scorm_useBeaconAPI = true; }; - // Listen to the four events known to represent an unload operation. - window.addEventListener('beforeunload', toggle); - window.addEventListener('unload', toggle); - window.addEventListener('pagehide', toggle); - window.addEventListener('visibilitychange', toggle); -})(); \ No newline at end of file + + /** + * Turns off the use of the Beacon API. + */ + var toggleOff = function() { + window.mod_scorm_useBeaconAPI = false; + }; + + /** + * Observes an event. + * Required because this patch will be backported. + * @param {string} on + * @param {CallableFunction} callback + */ + var observe = function(on, callback) { + if (!target.addEventListener) { + console.log('Unable to attach page dismissal event listeners'); + return null; + } + return target.addEventListener(on, callback); + }; + + // Listen to the three events known to represent an unload operation. + observe('beforeunload', toggleOn); + observe('unload', toggleOn); + observe('pagehide', toggleOn); + + // Listen to the event fired when navigating to a page and ensure we toggle useBeaconAPI off. + // This shouldn't be needed (page should be uncached) but just in case! + observe('pageshow', toggleOff); + + // Finally listen to the visibility change event, and respond to it. + // This unfortunately is not ideal, but is required as a SCORM package may also be listening to this and + // trying to save content when the user hides the page. As this can occur as part of the page dismissal lifecycle + // we also need to ensure we use the Beacon API here. + observe('visibilitychange', function() { + // Visible means synchronous XHR permitted, use XHR. + // Hidden means synchronous XHR not permitted, use Beacon API. + if (document.visibilityState === 'visible' || document.visibilityState === 'prerender') { + toggleOff(); + } else if (document.visibilityState === 'hidden') { + toggleOn(); + } + }); +} +// Begin monitoring on the main window immediately. +mod_scorm_monitorForBeaconRequirement(window); \ No newline at end of file