luci-base: luci.js: add HTTP request functions
authorJo-Philipp Wich <jo@mein.io>
Sun, 6 Jan 2019 16:08:37 +0000 (17:08 +0100)
committerJo-Philipp Wich <jo@mein.io>
Sun, 7 Jul 2019 13:25:49 +0000 (15:25 +0200)
Add a fetch() inspired HTTP request utility class to luci.js and
replace the old xhr.js class with a stub using the new request api.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
modules/luci-base/htdocs/luci-static/resources/luci.js
modules/luci-base/htdocs/luci-static/resources/xhr.js

index 8d7f88d77bd32300e174ddb8290fbe97598e4bd8..5fb0bcfe79106644681c4185442562de9e90c40e 100644 (file)
                }
        });
 
+
+       /*
+        * HTTP Request helper
+        */
+
+       Headers = Class.extend({
+               __name__: 'LuCI.XHR.Headers',
+               __init__: function(xhr) {
+                       var hdrs = this.headers = {};
+                       xhr.getAllResponseHeaders().split(/\r\n/).forEach(function(line) {
+                               var m = /^([^:]+):(.*)$/.exec(line);
+                               if (m != null)
+                                       hdrs[m[1].trim().toLowerCase()] = m[2].trim();
+                       });
+               },
+
+               has: function(name) {
+                       return this.headers.hasOwnProperty(String(name).toLowerCase());
+               },
+
+               get: function(name) {
+                       var key = String(name).toLowerCase();
+                       return this.headers.hasOwnProperty(key) ? this.headers[key] : null;
+               }
+       });
+
+       Response = Class.extend({
+               __name__: 'LuCI.XHR.Response',
+               __init__: function(xhr, url, duration) {
+                       this.ok = (xhr.status >= 200 && xhr.status <= 299);
+                       this.status = xhr.status;
+                       this.statusText = xhr.statusText;
+                       this.responseText = xhr.responseText;
+                       this.headers = new Headers(xhr);
+                       this.duration = duration;
+                       this.url = url;
+                       this.xhr = xhr;
+               },
+
+               json: function() {
+                       return JSON.parse(this.responseText);
+               },
+
+               text: function() {
+                       return this.responseText;
+               }
+       });
+
+       Request = Class.singleton({
+               __name__: 'LuCI.Request',
+
+               interceptors: [],
+
+               request: function(target, options) {
+                       var state = { xhr: new XMLHttpRequest(), url: target, start: Date.now() },
+                           opt = Object.assign({}, options, state),
+                           content = null,
+                           contenttype = null,
+                           callback = this.handleReadyStateChange;
+
+                       return new Promise(function(resolveFn, rejectFn) {
+                               opt.xhr.onreadystatechange = callback.bind(opt, resolveFn, rejectFn);
+                               opt.method = String(opt.method || 'GET').toUpperCase();
+
+                               if ('query' in opt) {
+                                       var q = (opt.query != null) ? Object.keys(opt.query).map(function(k) {
+                                               if (opt.query[k] != null) {
+                                                       var v = (typeof(opt.query[k]) == 'object')
+                                                               ? JSON.stringify(opt.query[k])
+                                                               : String(opt.query[k]);
+
+                                                       return '%s=%s'.format(encodeURIComponent(k), encodeURIComponent(v));
+                                               }
+                                               else {
+                                                       return encodeURIComponent(k);
+                                               }
+                                       }).join('&') : '';
+
+                                       if (q !== '') {
+                                               switch (opt.method) {
+                                               case 'GET':
+                                               case 'HEAD':
+                                               case 'OPTIONS':
+                                                       opt.url += ((/\?/).test(opt.url) ? '&' : '?') + q;
+                                                       break;
+
+                                               default:
+                                                       if (content == null) {
+                                                               content = q;
+                                                               contenttype = 'application/x-www-form-urlencoded';
+                                                       }
+                                               }
+                                       }
+                               }
+
+                               if (!opt.cache)
+                                       opt.url += ((/\?/).test(opt.url) ? '&' : '?') + (new Date()).getTime();
+
+                               if (!/^(?:[^/]+:)?\/\//.test(opt.url))
+                                       opt.url = location.protocol + '//' + location.host + opt.url;
+
+                               if ('username' in opt && 'password' in opt)
+                                       opt.xhr.open(opt.method, opt.url, true, opt.username, opt.password);
+                               else
+                                       opt.xhr.open(opt.method, opt.url, true);
+
+                               opt.xhr.responseType = 'text';
+                               opt.xhr.overrideMimeType('application/octet-stream');
+
+                               if ('timeout' in opt)
+                                       opt.xhr.timeout = +opt.timeout;
+
+                               if ('credentials' in opt)
+                                       opt.xhr.withCredentials = !!opt.credentials;
+
+                               if (opt.content != null) {
+                                       switch (typeof(opt.content)) {
+                                       case 'function':
+                                               content = opt.content(xhr);
+                                               break;
+
+                                       case 'object':
+                                               content = JSON.stringify(opt.content);
+                                               contenttype = 'application/json';
+                                               break;
+
+                                       default:
+                                               content = String(opt.content);
+                                       }
+                               }
+
+                               if ('headers' in opt)
+                                       for (var header in opt.headers)
+                                               if (opt.headers.hasOwnProperty(header)) {
+                                                       if (header.toLowerCase() != 'content-type')
+                                                               opt.xhr.setRequestHeader(header, opt.headers[header]);
+                                                       else
+                                                               contenttype = opt.headers[header];
+                                               }
+
+                               if (contenttype != null)
+                                       opt.xhr.setRequestHeader('Content-Type', contenttype);
+
+                               try {
+                                       opt.xhr.send(content);
+                               }
+                               catch (e) {
+                                       rejectFn.call(opt, e);
+                               }
+                       });
+               },
+
+               handleReadyStateChange: function(resolveFn, rejectFn, ev) {
+                       var xhr = this.xhr;
+
+                       if (xhr.readyState !== 4)
+                               return;
+
+                       if (xhr.status === 0 && xhr.statusText === '') {
+                               rejectFn.call(this, new Error('XHR request aborted by browser'));
+                       }
+                       else {
+                               var response = new Response(
+                                       xhr, xhr.responseURL || this.url, Date.now() - this.start);
+
+                               Promise.all(Request.interceptors.map(function(fn) { return fn(response) }))
+                                       .then(resolveFn.bind(this, response))
+                                       .catch(rejectFn.bind(this));
+                       }
+
+                       try {
+                               xhr.abort();
+                       }
+                       catch(e) {}
+               },
+
+               get: function(url, options) {
+                       return this.request(url, Object.assign({ method: 'GET' }, options));
+               },
+
+               post: function(url, data, options) {
+                       return this.request(url, Object.assign({ method: 'POST', content: data }, options));
+               },
+
+               addInterceptor: function(interceptorFn) {
+                       if (typeof(interceptorFn) == 'function')
+                               this.interceptors.push(interceptorFn);
+                       return interceptorFn;
+               },
+
+               removeInterceptor: function(interceptorFn) {
+                       var oldlen = this.interceptors.length, i = oldlen;
+                       while (i--)
+                               if (this.interceptors[i] === interceptorFn)
+                                       this.interceptors.splice(i, 1);
+                       return (this.interceptors.length < oldlen);
+               },
+
+               poll: Class.singleton({
+                       __name__: 'LuCI.Request.Poll',
+
+                       queue: [],
+
+                       add: function(interval, url, options, callback) {
+                               if (isNaN(interval) || interval <= 0)
+                                       throw new TypeError('Invalid poll interval');
+
+                               var e = {
+                                       interval: interval,
+                                       url: url,
+                                       options: options,
+                                       callback: callback
+                               };
+
+                               this.queue.push(e);
+                               return e;
+                       },
+
+                       remove: function(entry) {
+                               var oldlen = this.queue.length, i = oldlen;
+
+                               while (i--)
+                                       if (this.queue[i] === entry) {
+                                               delete this.queue[i].running;
+                                               this.queue.splice(i, 1);
+                                       }
+
+                               if (!this.queue.length)
+                                       this.stop();
+
+                               return (this.queue.length < oldlen);
+                       },
+
+                       start: function() {
+                               if (!this.queue.length || this.active())
+                                       return false;
+
+                               this.tick = 0;
+                               this.timer = window.setInterval(this.step, 1000);
+                               this.step();
+                               document.dispatchEvent(new CustomEvent('poll-start'));
+                               return true;
+                       },
+
+                       stop: function() {
+                               if (!this.active())
+                                       return false;
+
+                               document.dispatchEvent(new CustomEvent('poll-stop'));
+                               window.clearInterval(this.timer);
+                               delete this.timer;
+                               delete this.tick;
+                               return true;
+                       },
+
+                       step: function() {
+                               Request.poll.queue.forEach(function(e) {
+                                       if ((Request.poll.tick % e.interval) != 0)
+                                               return;
+
+                                       if (e.running)
+                                               return;
+
+                                       var opts = Object.assign({}, e.options,
+                                               { timeout: e.interval * 1000 - 5 });
+
+                                       e.running = true;
+                                       Request.request(e.url, opts)
+                                               .then(function(res) {
+                                                       if (!e.running || !Request.poll.active())
+                                                               return;
+
+                                                       try {
+                                                               e.callback(res, res.json(), res.duration);
+                                                       }
+                                                       catch (err) {
+                                                               e.callback(res, null, res.duration);
+                                                       }
+                                               })
+                                               .finally(function() { delete e.running });
+                               });
+
+                               Request.poll.tick = (Request.poll.tick + 1) % Math.pow(2, 32);
+                       },
+
+                       active: function() {
+                               return (this.timer != null);
+                       }
+               })
+       });
+
+
        var modalDiv = null,
            tooltipDiv = null,
            tooltipTimeout = null,
 
                /* HTTP resource fetching */
                get: function(url, args, cb) {
-                       return this.poll(0, url, args, cb, false);
+                       return this.poll(null, url, args, cb, false);
                },
 
                post: function(url, args, cb) {
-                       return this.poll(0, url, args, cb, true);
+                       return this.poll(null, url, args, cb, true);
                },
 
                poll: function(interval, url, args, cb, post) {
-                       var data = post ? { token: this.env.token } : null;
+                       if (interval !== null && interval <= 0)
+                               interval = this.env.pollinterval;
+
+                       var data = post ? { token: this.env.token } : null,
+                           method = post ? 'POST' : 'GET';
 
                        if (!/^(?:\/|\S+:\/\/)/.test(url))
                                url = this.url(url);
 
-                       if (typeof(args) === 'object' && args !== null) {
-                               data = data || {};
-
-                               for (var key in args)
-                                       if (args.hasOwnProperty(key))
-                                               switch (typeof(args[key])) {
-                                               case 'string':
-                                               case 'number':
-                                               case 'boolean':
-                                                       data[key] = args[key];
-                                                       break;
-
-                                               case 'object':
-                                                       data[key] = JSON.stringify(args[key]);
-                                                       break;
-                                               }
-                       }
+                       if (args != null)
+                               data = Object.assign(data || {}, args);
 
-                       if (interval > 0)
-                               return XHR.poll(interval, url, data, cb, post);
-                       else if (post)
-                               return XHR.post(url, data, cb);
+                       if (interval !== null)
+                               return Request.poll.add(interval, url, { method: method, query: data }, cb);
                        else
-                               return XHR.get(url, data, cb);
+                               return Request.request(url, { method: method, query: data })
+                                       .then(function(res) {
+                                               var json = null;
+                                               if (/^application\/json\b/.test(res.headers.get('Content-Type')))
+                                                       try { json = res.json() } catch(e) {}
+                                               cb(res.xhr, json, res.duration);
+                                       });
                },
 
-               stop: function(entry) { XHR.stop(entry) },
-               halt: function() { XHR.halt() },
-               run: function() { XHR.run() },
+               stop: function(entry) { return Request.poll.remove(entry) },
+               halt: function() { return Request.poll.stop() },
+               run: function() { return Request.poll.start() },
 
 
                /* Modal dialog */
                        return node;
                },
 
-               Class: Class
+               Class: Class,
+               Request: Request
        };
 
        /* Tabs */
        /* Setup */
        LuCI.prototype.setupDOM = function(ev) {
                this.tabs.init();
+
+               Request.addInterceptor(function(res) {
+                       if (res.status != 403 || res.headers.get('X-LuCI-Login-Required') != 'yes')
+                               return;
+
+                       Request.poll.stop();
+
+                       L.showModal(_('Session expired'), [
+                               E('div', { class: 'alert-message warning' },
+                                       _('A new login is required since the authentication session expired.')),
+                               E('div', { class: 'right' },
+                                       E('div', {
+                                               class: 'btn primary',
+                                               click: function() {
+                                                       var loc = window.location;
+                                                       window.location = loc.protocol + '//' + loc.host + loc.pathname + loc.search;
+                                               }
+                                       }, _('To login…')))
+                       ]);
+
+                       return Promise.reject(new Error('Session expired'));
+               });
+
+               Request.poll.start();
        };
 
        function LuCI(env) {
                document.addEventListener('focus', this.showTooltip.bind(this), true);
                document.addEventListener('blur', this.hideTooltip.bind(this), true);
 
+               document.addEventListener('poll-start', function(ev) {
+                       document.querySelectorAll('[id^="xhr_poll_status"]').forEach(function(e) {
+                               e.style.display = (e.id == 'xhr_poll_status_off') ? 'none' : '';
+                       });
+               });
+
+               document.addEventListener('poll-stop', function(ev) {
+                       document.querySelectorAll('[id^="xhr_poll_status"]').forEach(function(e) {
+                               e.style.display = (e.id == 'xhr_poll_status_on') ? 'none' : '';
+                       });
+               });
+
                document.addEventListener('DOMContentLoaded', this.setupDOM.bind(this));
        }
 
+       XHR = Class.extend({
+               __name__: 'LuCI.XHR',
+               __init__: function() {
+                       if (window.console && console.debug)
+                               console.debug('Direct use XHR() is deprecated, please use L.Request instead');
+               },
+
+               _response: function(cb, res, json, duration) {
+                       if (this.active)
+                               cb(res, json, duration);
+                       delete this.active;
+               },
+
+               get: function(url, data, callback, timeout) {
+                       this.active = true;
+                       L.get(url, data, this._response.bind(this, callback), timeout);
+               },
+
+               post: function(url, data, callback, timeout) {
+                       this.active = true;
+                       L.post(url, data, this._response.bind(this, callback), timeout);
+               },
+
+               cancel: function() { delete this.active },
+               busy: function() { return (this.active === true) },
+               abort: function() {},
+               send_form: function() { throw 'Not implemented' },
+       });
+
+       XHR.get = function() { return window.L.get.apply(window.L, arguments) };
+       XHR.post = function() { return window.L.post.apply(window.L, arguments) };
+       XHR.poll = function() { return window.L.poll.apply(window.L, arguments) };
+       XHR.stop = Request.poll.remove.bind(Request.poll);
+       XHR.halt = Request.poll.stop.bind(Request.poll);
+       XHR.run = Request.poll.start.bind(Request.poll);
+       XHR.running = Request.poll.active.bind(Request.poll);
+
+       window.XHR = XHR;
        window.LuCI = LuCI;
 })(window, document);
index 3133898b5e84415f3be57b20c73484d46848e8dc..10bc88e1f43243f963649d2bce493814c6ef1623 100644 (file)
@@ -1,250 +1 @@
-/*
- * xhr.js - XMLHttpRequest helper class
- * (c) 2008-2018 Jo-Philipp Wich <jo@mein.io>
- */
-
-XHR.prototype = {
-       _encode: function(obj) {
-               obj = obj ? obj : { };
-               obj['_'] = Math.random();
-
-               if (typeof obj == 'object') {
-                       var code = '';
-                       var self = this;
-
-                       for (var k in obj)
-                               code += (code ? '&' : '') +
-                                       k + '=' + encodeURIComponent(obj[k]);
-
-                       return code;
-               }
-
-               return obj;
-       },
-
-       _response: function(callback, ts) {
-               if (this._xmlHttp.readyState !== 4)
-                       return;
-
-               var status = this._xmlHttp.status,
-                   login = this._xmlHttp.getResponseHeader("X-LuCI-Login-Required"),
-                   type = this._xmlHttp.getResponseHeader("Content-Type"),
-                   json = null;
-
-               if (status === 403 && login === 'yes') {
-                       XHR.halt();
-
-                       showModal(_('Session expired'), [
-                               E('div', { class: 'alert-message warning' },
-                                       _('A new login is required since the authentication session expired.')),
-                               E('div', { class: 'right' },
-                                       E('div', {
-                                               class: 'btn primary',
-                                               click: function() {
-                                                       var loc = window.location;
-                                                       window.location = loc.protocol + '//' + loc.host + loc.pathname + loc.search;
-                                               }
-                                       }, _('To login…')))
-                       ]);
-               }
-               else if (type && type.toLowerCase().match(/^application\/json\b/)) {
-                       try {
-                               json = JSON.parse(this._xmlHttp.responseText);
-                       }
-                       catch(e) {
-                               json = null;
-                       }
-               }
-
-               callback(this._xmlHttp, json, Date.now() - ts);
-       },
-
-       busy: function() {
-               if (!this._xmlHttp)
-                       return false;
-
-               switch (this._xmlHttp.readyState)
-               {
-                       case 1:
-                       case 2:
-                       case 3:
-                               return true;
-
-                       default:
-                               return false;
-               }
-       },
-
-       abort: function() {
-               if (this.busy())
-                       this._xmlHttp.abort();
-       },
-
-       get: function(url, data, callback, timeout) {
-               this._xmlHttp = new XMLHttpRequest();
-
-               var xhr = this._xmlHttp,
-                   code = this._encode(data);
-
-               url = location.protocol + '//' + location.host + url;
-
-               if (code)
-                       if (url.substr(url.length-1,1) == '&')
-                               url += code;
-                       else
-                               url += '?' + code;
-
-               xhr.open('GET', url, true);
-
-               if (!isNaN(timeout))
-                       xhr.timeout = timeout;
-
-               xhr.onreadystatechange = this._response.bind(this, callback, Date.now());
-               xhr.send(null);
-       },
-
-       post: function(url, data, callback, timeout) {
-               this._xmlHttp = new XMLHttpRequest();
-
-               var xhr = this._xmlHttp,
-                   code = this._encode(data);
-
-               xhr.open('POST', url, true);
-
-               if (!isNaN(timeout))
-                       xhr.timeout = timeout;
-
-               xhr.onreadystatechange = this._response.bind(this, callback, Date.now());
-               xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
-               xhr.send(code);
-       },
-
-       cancel: function() {
-               this._xmlHttp.onreadystatechange = function() {};
-               this._xmlHttp.abort();
-       },
-
-       send_form: function(form, callback, extra_values) {
-               var code = '';
-
-               for (var i = 0; i < form.elements.length; i++) {
-                       var e = form.elements[i];
-
-                       if (e.options) {
-                               code += (code ? '&' : '') +
-                                       form.elements[i].name + '=' + encodeURIComponent(
-                                               e.options[e.selectedIndex].value
-                                       );
-                       }
-                       else if (e.length) {
-                               for (var j = 0; j < e.length; j++)
-                                       if (e[j].name) {
-                                               code += (code ? '&' : '') +
-                                                       e[j].name + '=' + encodeURIComponent(e[j].value);
-                                       }
-                       }
-                       else {
-                               code += (code ? '&' : '') +
-                                       e.name + '=' + encodeURIComponent(e.value);
-                       }
-               }
-
-               if (typeof extra_values == 'object')
-                       for (var key in extra_values)
-                               code += (code ? '&' : '') +
-                                       key + '=' + encodeURIComponent(extra_values[key]);
-
-               return (form.method == 'get'
-                       ? this.get(form.getAttribute('action'), code, callback)
-                       : this.post(form.getAttribute('action'), code, callback));
-       }
-}
-
-XHR.get = function(url, data, callback) {
-       (new XHR()).get(url, data, callback);
-}
-
-XHR.post = function(url, data, callback) {
-       (new XHR()).post(url, data, callback);
-}
-
-XHR.poll = function(interval, url, data, callback, post) {
-       if (isNaN(interval) || interval <= 0)
-               interval = L.env.pollinterval;
-
-       if (!XHR._q) {
-               XHR._t = 0;
-               XHR._q = [ ];
-               XHR._r = function() {
-                       for (var i = 0, e = XHR._q[0]; i < XHR._q.length; e = XHR._q[++i])
-                       {
-                               if (!(XHR._t % e.interval) && !e.xhr.busy())
-                                       e.xhr[post ? 'post' : 'get'](e.url, e.data, e.callback, e.interval * 1000 * 5 - 5);
-                       }
-
-                       XHR._t++;
-               };
-       }
-
-       var e = {
-               interval: interval,
-               callback: callback,
-               url:      url,
-               data:     data,
-               xhr:      new XHR()
-       };
-
-       XHR._q.push(e);
-
-       return e;
-}
-
-XHR.stop = function(e) {
-       for (var i = 0; XHR._q && XHR._q[i]; i++) {
-               if (XHR._q[i] === e) {
-                       e.xhr.cancel();
-                       XHR._q.splice(i, 1);
-                       return true;
-               }
-       }
-
-       return false;
-}
-
-XHR.halt = function() {
-       if (XHR._i) {
-               /* show & set poll indicator */
-               try {
-                       document.getElementById('xhr_poll_status').style.display = '';
-                       document.getElementById('xhr_poll_status_on').style.display = 'none';
-                       document.getElementById('xhr_poll_status_off').style.display = '';
-               } catch(e) { }
-
-               window.clearInterval(XHR._i);
-               XHR._i = null;
-       }
-}
-
-XHR.run = function() {
-       if (XHR._r && !XHR._i) {
-               /* show & set poll indicator */
-               try {
-                       document.getElementById('xhr_poll_status').style.display = '';
-                       document.getElementById('xhr_poll_status_on').style.display = '';
-                       document.getElementById('xhr_poll_status_off').style.display = 'none';
-               } catch(e) { }
-
-               /* kick first round manually to prevent one second lag when setting up
-                * the poll interval */
-               XHR._r();
-               XHR._i = window.setInterval(XHR._r, 1000);
-       }
-}
-
-XHR.running = function() {
-       return !!(XHR._r && XHR._i);
-}
-
-function XHR() {}
-
-document.addEventListener('DOMContentLoaded', XHR.run);
+/* replaced by luci.js */