From: Jo-Philipp Wich Date: Sun, 6 Jan 2019 16:08:37 +0000 (+0100) Subject: luci-base: luci.js: add HTTP request functions X-Git-Url: http://git.lede-project.org./?a=commitdiff_plain;h=843031f8197aa2c86585360c0f6d8a3b1ee02dee;p=project%2Fluci.git luci-base: luci.js: add HTTP request functions 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 --- diff --git a/modules/luci-base/htdocs/luci-static/resources/luci.js b/modules/luci-base/htdocs/luci-static/resources/luci.js index 8d7f88d77b..5fb0bcfe79 100644 --- a/modules/luci-base/htdocs/luci-static/resources/luci.js +++ b/modules/luci-base/htdocs/luci-static/resources/luci.js @@ -131,6 +131,298 @@ } }); + + /* + * 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, @@ -167,48 +459,41 @@ /* 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 */ @@ -312,7 +597,8 @@ return node; }, - Class: Class + Class: Class, + Request: Request }; /* Tabs */ @@ -622,6 +908,30 @@ /* 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) { @@ -638,8 +948,58 @@ 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); diff --git a/modules/luci-base/htdocs/luci-static/resources/xhr.js b/modules/luci-base/htdocs/luci-static/resources/xhr.js index 3133898b5e..10bc88e1f4 100644 --- a/modules/luci-base/htdocs/luci-static/resources/xhr.js +++ b/modules/luci-base/htdocs/luci-static/resources/xhr.js @@ -1,250 +1 @@ -/* - * xhr.js - XMLHttpRequest helper class - * (c) 2008-2018 Jo-Philipp Wich - */ - -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 */