/** * JsHttpRequest: JavaScript "AJAX" data loader * * @license LGPL * @author Dmitry Koterov, http://en.dklab.ru/lib/JsHttpRequest/ * @version 5.x $Id$ */ // {{{ function JsHttpRequest() { // Standard properties. var t = this; t.onreadystatechange = null; t.readyState = 0; t.responseText = null; t.responseXML = null; t.status = 200; t.statusText = "OK"; // JavaScript response array/hash t.responseJS = null; // Additional properties. t.caching = false; // need to use caching? t.loader = null; // loader to use ('form', 'script', 'xml'; null - autodetect) t.session_name = "PHPSESSID"; // set to SID cookie or GET parameter name // Internals. t._ldObj = null; // used loader object t._reqHeaders = []; // collected request headers t._openArgs = null; // parameters from open() t._errors = { inv_form_el: 'Invalid FORM element detected: name=%, tag=%', must_be_single_el: 'If used,
must be a single HTML element in the list.', js_invalid: 'JavaScript code generated by backend is invalid!\n%', url_too_long: 'Cannot use so long query with GET request (URL is larger than % bytes)', unk_loader: 'Unknown loader: %', no_loaders: 'No loaders registered at all, please check JsHttpRequest.LOADERS array', no_loader_matched: 'Cannot find a loader which may process the request. Notices are:\n%' } /** * Aborts the request. Behaviour of this function for onreadystatechange() * is identical to IE (most universal and common case). E.g., readyState -> 4 * on abort() after send(). */ t.abort = function() { with (this) { if (_ldObj && _ldObj.abort) _ldObj.abort(); _cleanup(); if (readyState == 0) { // start->abort: no change of readyState (IE behaviour) return; } if (readyState == 1 && !_ldObj) { // open->abort: no onreadystatechange call, but change readyState to 0 (IE). // send->abort: change state to 4 (_ldObj is not null when send() is called) readyState = 0; return; } _changeReadyState(4, true); // 4 in IE & FF on abort() call; Opera does not change to 4. }} /** * Prepares the object for data loading. * You may also pass URLs like "GET url" or "script.GET url". */ t.open = function(method, url, asyncFlag, username, password) { with (this) { // Extract methor and loader from the URL (if present). if (url.match(/^((\w+)\.)?(GET|POST)\s+(.*)/i)) { this.loader = RegExp.$2? RegExp.$2 : null; method = RegExp.$3; url = RegExp.$4; } // Append SID to original URL. Use try...catch for security problems. try { if ( document.location.search.match(new RegExp('[&?]' + session_name + '=([^&?]*)')) || document.cookie.match(new RegExp('(?:;|^)\\s*' + session_name + '=([^;]*)')) ) { url += (url.indexOf('?') >= 0? '&' : '?') + session_name + "=" + this.escape(RegExp.$1); } } catch (e) {} // Store open arguments to hash. _openArgs = { method: (method || '').toUpperCase(), url: url, asyncFlag: asyncFlag, username: username != null? username : '', password: password != null? password : '' } _ldObj = null; _changeReadyState(1, true); // compatibility with XMLHttpRequest return true; }} /** * Sends a request to a server. */ t.send = function(content) { if (!this.readyState) { // send without open or after abort: no action (IE behaviour). return; } this._changeReadyState(1, true); // compatibility with XMLHttpRequest this._ldObj = null; // Prepare to build QUERY_STRING from query hash. var queryText = []; var queryElem = []; if (!this._hash2query(content, null, queryText, queryElem)) return; // Solve the query hashcode & return on cache hit. var hash = null; if (this.caching && !queryElem.length) { hash = this._openArgs.username + ':' + this._openArgs.password + '@' + this._openArgs.url + '|' + queryText + "#" + this._openArgs.method; var cache = JsHttpRequest.CACHE[hash]; if (cache) { this._dataReady(cache[0], cache[1]); return false; } } // Try all the loaders. var loader = (this.loader || '').toLowerCase(); if (loader && !JsHttpRequest.LOADERS[loader]) return this._error('unk_loader', loader); var errors = []; var lds = JsHttpRequest.LOADERS; for (var tryLoader in lds) { var ldr = lds[tryLoader].loader; if (!ldr) continue; // exclude possibly derived prototype properties from "for .. in". if (loader && tryLoader != loader) continue; // Create sending context. var ldObj = new ldr(this); JsHttpRequest.extend(ldObj, this._openArgs); JsHttpRequest.extend(ldObj, { queryText: queryText.join('&'), queryElem: queryElem, id: (new Date().getTime()) + "" + JsHttpRequest.COUNT++, hash: hash, span: null }); var error = ldObj.load(); if (!error) { // Save loading script. this._ldObj = ldObj; JsHttpRequest.PENDING[ldObj.id] = this; return true; } if (!loader) { errors[errors.length] = '- ' + tryLoader.toUpperCase() + ': ' + this._l(error); } else { return this._error(error); } } // If no loader matched, generate error message. return tryLoader? this._error('no_loader_matched', errors.join('\n')) : this._error('no_loaders'); } /** * Returns all response headers (if supported). */ t.getAllResponseHeaders = function() { with (this) { return _ldObj && _ldObj.getAllResponseHeaders? _ldObj.getAllResponseHeaders() : []; }} /** * Returns one response header (if supported). */ t.getResponseHeader = function(label) { with (this) { return _ldObj && _ldObj.getResponseHeader? _ldObj.getResponseHeader(label) : null; }} /** * Adds a request header to a future query. */ t.setRequestHeader = function(label, value) { with (this) { _reqHeaders[_reqHeaders.length] = [label, value]; }} // // Internal functions. // /** * Do all the work when a data is ready. */ t._dataReady = function(text, js) { with (this) { if (caching && _ldObj) JsHttpRequest.CACHE[_ldObj.hash] = [text, js]; responseText = responseXML = text; responseJS = js; if (js !== null) { status = 200; statusText = "OK"; } else { status = 500; statusText = "Internal Server Error"; } _changeReadyState(2); _changeReadyState(3); _changeReadyState(4); _cleanup(); }} /** * Analog of sprintf(), but translates the first parameter by _errors. */ t._l = function(args) { var i = 0, p = 0, msg = this._errors[args[0]]; // Cannot use replace() with a callback, because it is incompatible with IE5. while ((p = msg.indexOf('%', p)) >= 0) { var a = args[++i] + ""; msg = msg.substring(0, p) + a + msg.substring(p + 1, msg.length); p += 1 + a.length; } return msg; } /** * Called on error. */ t._error = function(msg) { msg = this._l(typeof(msg) == 'string'? arguments : msg) msg = "JsHttpRequest: " + msg; if (!window.Error) { // Very old browser... throw msg; } else if ((new Error(1, 'test')).description == "test") { // We MUST (!!!) pass 2 parameters to the Error() constructor for IE5. throw new Error(1, msg); } else { // Mozilla does not support two-parameter call style. throw new Error(msg); } } /** * Convert hash to QUERY_STRING. * If next value is scalar or hash, push it to queryText. * If next value is form element, push [name, element] to queryElem. */ t._hash2query = function(content, prefix, queryText, queryElem) { if (prefix == null) prefix = ""; if((''+typeof(content)).toLowerCase() == 'object') { var formAdded = false; if (content && content.parentNode && content.parentNode.appendChild && content.tagName && content.tagName.toUpperCase() == 'FORM') { content = { form: content }; } for (var k in content) { var v = content[k]; if (v instanceof Function) continue; var curPrefix = prefix? prefix + '[' + this.escape(k) + ']' : this.escape(k); var isFormElement = v && v.parentNode && v.parentNode.appendChild && v.tagName; if (isFormElement) { var tn = v.tagName.toUpperCase(); if (tn == 'FORM') { // FORM itself is passed. formAdded = true; } else if (tn == 'INPUT' || tn == 'TEXTAREA' || tn == 'SELECT') { // This is a single form elemenent. } else { return this._error('inv_form_el', (v.name||''), v.tagName); } queryElem[queryElem.length] = { name: curPrefix, e: v }; } else if (v instanceof Object) { this._hash2query(v, curPrefix, queryText, queryElem); } else { // We MUST skip NULL values, because there is no method // to pass NULL's via GET or POST request in PHP. if (v === null) continue; // Convert JS boolean true and false to corresponding PHP values. if (v === true) v = 1; if (v === false) v = ''; queryText[queryText.length] = curPrefix + "=" + this.escape('' + v); } if (formAdded && queryElem.length > 1) { return this._error('must_be_single_el'); } } } else { queryText[queryText.length] = content; } return true; } /** * Remove last used script element (clean memory). */ t._cleanup = function() { var ldObj = this._ldObj; if (!ldObj) return; // Mark this loading as aborted. JsHttpRequest.PENDING[ldObj.id] = false; var span = ldObj.span; if (!span) return; ldObj.span = null; var closure = function() { span.parentNode.removeChild(span); } // IE5 crashes on setTimeout(function() {...}, ...) construction! Use tmp variable. JsHttpRequest.setTimeout(closure, 50); } /** * Change current readyState and call trigger method. */ t._changeReadyState = function(s, reset) { with (this) { if (reset) { status = statusText = responseJS = null; responseText = ''; } readyState = s; if (onreadystatechange) onreadystatechange(); }} /** * JS escape() does not quote '+'. */ t.escape = function(s) { return escape(s).replace(new RegExp('\\+','g'), '%2B'); } } // Global library variables. JsHttpRequest.COUNT = 0; // unique ID; used while loading IDs generation JsHttpRequest.MAX_URL_LEN = 2000; // maximum URL length JsHttpRequest.CACHE = {}; // cached data JsHttpRequest.PENDING = {}; // pending loadings JsHttpRequest.LOADERS = {}; // list of supported data loaders (filled at the bottom of the file) JsHttpRequest._dummy = function() {}; // avoid memory leaks /** * These functions are dirty hacks for IE 5.0 which does not increment a * reference counter for an object passed via setTimeout(). So, if this * object (closure function) is out of scope at the moment of timeout * applying, IE 5.0 crashes. */ /** * Timeout wrappers storage. Used to avoid zeroing of referece counts in IE 5.0. * Please note that you MUST write "window.setTimeout", not "setTimeout", else * IE 5.0 crashes again. Strange, very strange... */ JsHttpRequest.TIMEOUTS = { s: window.setTimeout, c: window.clearTimeout }; /** * Wrapper for IE5 buggy setTimeout. * Use this function instead of a usual setTimeout(). */ JsHttpRequest.setTimeout = function(func, dt) { // Always save inside the window object before a call (for FF)! window.JsHttpRequest_tmp = JsHttpRequest.TIMEOUTS.s; if (typeof(func) == "string") { id = window.JsHttpRequest_tmp(func, dt); } else { var id = null; var mediator = function() { func(); delete JsHttpRequest.TIMEOUTS[id]; // remove circular reference } id = window.JsHttpRequest_tmp(mediator, dt); // Store a reference to the mediator function to the global array // (reference count >= 1); use timeout ID as an array key; JsHttpRequest.TIMEOUTS[id] = mediator; } window.JsHttpRequest_tmp = null; // no delete() in IE5 for window return id; } /** * Complimental wrapper for clearTimeout. * Use this function instead of usual clearTimeout(). */ JsHttpRequest.clearTimeout = function(id) { window.JsHttpRequest_tmp = JsHttpRequest.TIMEOUTS.c; delete JsHttpRequest.TIMEOUTS[id]; // remove circular reference var r = window.JsHttpRequest_tmp(id); window.JsHttpRequest_tmp = null; // no delete() in IE5 for window return r; } /** * Global static function. * Simple interface for most popular use-cases. * You may also pass URLs like "GET url" or "script.GET url". */ JsHttpRequest.query = function(url, content, onready, nocache) { var req = new this(); req.caching = !nocache; req.onreadystatechange = function() { if (req.readyState == 4) { onready(req.responseJS, req.responseText); } } req.open(null, url, true); req.send(content); } /** * Global static function. * Called by server backend script on data load. */ JsHttpRequest.dataReady = function(d) { var th = this.PENDING[d.id]; delete this.PENDING[d.id]; if (th) { th._dataReady(d.text, d.js); } else if (th !== false) { throw "dataReady(): unknown pending id: " + d.id; } } // Adds all the properties of src to dest. JsHttpRequest.extend = function(dest, src) { for (var k in src) dest[k] = src[k]; } /** * Each loader has the following properties which must be initialized: * - method * - url * - asyncFlag (ignored) * - username * - password * - queryText (string) * - queryElem (array) * - id * - hash * - span */ // }}} // {{{ xml // Loader: XMLHttpRequest or ActiveX. // [+] GET and POST methods are supported. // [+] Most native and memory-cheap method. // [+] Backend data can be browser-cached. // [-] Cannot work in IE without ActiveX. // [-] No support for loading from different domains. // [-] No uploading support. // JsHttpRequest.LOADERS.xml = { loader: function(req) { JsHttpRequest.extend(req._errors, { xml_no: 'Cannot use XMLHttpRequest or ActiveX loader: not supported', xml_no_diffdom: 'Cannot use XMLHttpRequest to load data from different domain %', xml_no_headers: 'Cannot use XMLHttpRequest loader or ActiveX loader, POST method: headers setting is not supported, needed to work with encodings correctly', xml_no_form_upl: 'Cannot use XMLHttpRequest loader: direct form elements using and uploading are not implemented' }); this.load = function() { if (this.queryElem.length) return ['xml_no_form_upl']; // XMLHttpRequest (and MS ActiveX'es) cannot work with different domains. if (this.url.match(new RegExp('^([a-z]+://[^\\/]+)(.*)', 'i'))) { // We MUST also check if protocols matched: cannot send from HTTP // to HTTPS and vice versa. if (RegExp.$1.toLowerCase() != document.location.protocol + '//' + document.location.hostname.toLowerCase()) { return ['xml_no_diffdom', RegExp.$1]; } } // Try to obtain a loader. var xr = null; if (window.XMLHttpRequest) { try { xr = new XMLHttpRequest() } catch(e) {} } else if (window.ActiveXObject) { try { xr = new ActiveXObject("Microsoft.XMLHTTP") } catch(e) {} if (!xr) try { xr = new ActiveXObject("Msxml2.XMLHTTP") } catch (e) {} } if (!xr) return ['xml_no']; // Loading method detection. We cannot POST if we cannot set "octet-stream" // header, because we need to process the encoded data in the backend manually. var canSetHeaders = window.ActiveXObject || xr.setRequestHeader; if (!this.method) this.method = canSetHeaders && this.queryText.length? 'POST' : 'GET'; // Build & validate the full URL. if (this.method == 'GET') { if (this.queryText) this.url += (this.url.indexOf('?') >= 0? '&' : '?') + this.queryText; this.queryText = ''; if (this.url.length > JsHttpRequest.MAX_URL_LEN) return ['url_too_long', JsHttpRequest.MAX_URL_LEN]; } else if (this.method == 'POST' && !canSetHeaders) { return ['xml_no_headers']; } // Add ID to the url if we need to disable the cache. this.url += (this.url.indexOf('?') >= 0? '&' : '?') + 'JsHttpRequest=' + (req.caching? '0' : this.id) + '-xml'; // Assign the result handler. var id = this.id; xr.onreadystatechange = function() { if (xr.readyState != 4) return; // Avoid memory leak by removing the closure. xr.onreadystatechange = JsHttpRequest._dummy; req.status = null; try { // In case of abort() call, xr.status is unavailable and generates exception. // But xr.readyState equals to 4 in this case. Stupid behaviour. :-( req.status = xr.status; req.responseText = xr.responseText; } catch (e) {} if (!req.status) return; try { // Prepare generator function & catch syntax errors on this stage. eval('JsHttpRequest._tmp = function(id) { var d = ' + req.responseText + '; d.id = id; JsHttpRequest.dataReady(d); }'); } catch (e) { // Note that FF 2.0 does not throw any error from onreadystatechange handler. return req._error('js_invalid', req.responseText) } // Call associated dataReady() outside the try-catch block // to pass exceptions in onreadystatechange in usual manner. JsHttpRequest._tmp(id); JsHttpRequest._tmp = null; }; // Open & send the request. xr.open(this.method, this.url, true, this.username, this.password); if (canSetHeaders) { // Pass pending headers. for (var i = 0; i < req._reqHeaders.length; i++) { xr.setRequestHeader(req._reqHeaders[i][0], req._reqHeaders[i][1]); } // Set non-default Content-type. We cannot use // "application/x-www-form-urlencoded" here, because // in PHP variable HTTP_RAW_POST_DATA is accessible only when // enctype is not default (e.g., "application/octet-stream" // is a good start). We parse POST data manually in backend // library code. Note that Safari sets by default "x-www-form-urlencoded" // header, but FF sets "text/xml" by default. xr.setRequestHeader('Content-Type', 'application/octet-stream'); } xr.send(this.queryText); // No SPAN is used for this loader. this.span = null; this.xr = xr; // save for later usage on abort() // Success. return null; } // Override req.getAllResponseHeaders method. this.getAllResponseHeaders = function() { return this.xr.getAllResponseHeaders(); } // Override req.getResponseHeader method. this.getResponseHeader = function(label) { return this.xr.getResponseHeader(label); } this.abort = function() { this.xr.abort(); this.xr = null; } }} // }}} // {{{ script // Loader: SCRIPT tag. // [+] Most cross-browser. // [+] Supports loading from different domains. // [-] Only GET method is supported. // [-] No uploading support. // [-] Backend data cannot be browser-cached. // JsHttpRequest.LOADERS.script = { loader: function(req) { JsHttpRequest.extend(req._errors, { script_only_get: 'Cannot use SCRIPT loader: it supports only GET method', script_no_form: 'Cannot use SCRIPT loader: direct form elements using and uploading are not implemented' }) this.load = function() { // Move GET parameters to the URL itself. if (this.queryText) this.url += (this.url.indexOf('?') >= 0? '&' : '?') + this.queryText; this.url += (this.url.indexOf('?') >= 0? '&' : '?') + 'JsHttpRequest=' + this.id + '-' + 'script'; this.queryText = ''; if (!this.method) this.method = 'GET'; if (this.method !== 'GET') return ['script_only_get']; if (this.queryElem.length) return ['script_no_form']; if (this.url.length > JsHttpRequest.MAX_URL_LEN) return ['url_too_long', JsHttpRequest.MAX_URL_LEN]; var th = this, d = document, s = null, b = d.body; if (!window.opera) { // Safari, IE, FF, Opera 7.20. this.span = s = d.createElement('SCRIPT'); var closure = function() { s.language = 'JavaScript'; if (s.setAttribute) s.setAttribute('src', th.url); else s.src = th.url; b.insertBefore(s, b.lastChild); } } else { // Oh shit! Damned stupid Opera 7.23 does not allow to create SCRIPT // element over createElement (in HEAD or BODY section or in nested SPAN - // no matter): it is created deadly, and does not response the href assignment. // So - always create SPAN. this.span = s = d.createElement('SPAN'); s.style.display = 'none'; b.insertBefore(s, b.lastChild); s.innerHTML = 'Workaround for IE.'; var closure = function() { s = s.getElementsByTagName('SCRIPT')[0]; // get with timeout! s.language = 'JavaScript'; if (s.setAttribute) s.setAttribute('src', th.url); else s.src = th.url; } } JsHttpRequest.setTimeout(closure, 10); // Success. return null; } }} // }}} // {{{ form // Loader: FORM & IFRAME. // [+] Supports file uploading. // [+] GET and POST methods are supported. // [+] Supports loading from different domains. // [-] Uses a lot of system resources. // [-] Backend data cannot be browser-cached. // [-] Pollutes browser history on some old browsers. // JsHttpRequest.LOADERS.form = { loader: function(req) { JsHttpRequest.extend(req._errors, { form_el_not_belong: 'Element "%" does not belong to any form!', form_el_belong_diff: 'Element "%" belongs to a different form. All elements must belong to the same form!', form_el_inv_enctype: 'Attribute "enctype" of the form must be "%" (for IE), "%" given.' }) this.load = function() { var th = this; if (!th.method) th.method = 'POST'; th.url += (th.url.indexOf('?') >= 0? '&' : '?') + 'JsHttpRequest=' + th.id + '-' + 'form'; // If GET, build full URL. Then copy QUERY_STRING to queryText. if (th.method == 'GET') { if (th.queryText) th.url += (th.url.indexOf('?') >= 0? '&' : '?') + th.queryText; if (th.url.length > JsHttpRequest.MAX_URL_LEN) return ['url_too_long', JsHttpRequest.MAX_URL_LEN]; var p = th.url.split('?', 2); th.url = p[0]; th.queryText = p[1] || ''; } // Check if all form elements belong to same form. var form = null; var wholeFormSending = false; if (th.queryElem.length) { if (th.queryElem[0].e.tagName.toUpperCase() == 'FORM') { // Whole FORM sending. form = th.queryElem[0].e; wholeFormSending = true; th.queryElem = []; } else { // If we have at least one form element, we use its FORM as a POST container. form = th.queryElem[0].e.form; // Validate all the elements. for (var i = 0; i < th.queryElem.length; i++) { var e = th.queryElem[i].e; if (!e.form) { return ['form_el_not_belong', e.name]; } if (e.form != form) { return ['form_el_belong_diff', e.name]; } } } // Check enctype of the form. if (th.method == 'POST') { var need = "multipart/form-data"; var given = (form.attributes.encType && form.attributes.encType.nodeValue) || (form.attributes.enctype && form.attributes.enctype.value) || form.enctype; if (given != need) { return ['form_el_inv_enctype', need, given]; } } } // Create invisible IFRAME with temporary form (form is used on empty queryElem). // We ALWAYS create th IFRAME in the document of the form - for Opera 7.20. var d = form && (form.ownerDocument || form.document) || document; var ifname = 'jshr_i_' + th.id; var s = th.span = d.createElement('DIV'); s.style.position = 'absolute'; s.style.display = 'none'; s.style.visibility = 'hidden'; s.innerHTML = (form? '' : '') + // stupid IE, MUST use innerHTML assignment :-( '' if (!form) { form = th.span.firstChild; } // Insert generated form inside the document. // Be careful: don't forget to close FORM container in document body! d.body.insertBefore(s, d.body.lastChild); // Function to safely set the form attributes. Parameter attr is NOT a hash // but an array, because "for ... in" may badly iterate over derived attributes. var setAttributes = function(e, attr) { var sv = []; var form = e; // This strange algorythm is needed, because form may contain element // with name like 'action'. In IE for such attribute will be returned // form element node, not form action. Workaround: copy all attributes // to new empty form and work with it, then copy them back. This is // THE ONLY working algorythm since a lot of bugs in IE5.0 (e.g. // with e.attributes property: causes IE crash). if (e.mergeAttributes) { var form = d.createElement('form'); form.mergeAttributes(e, false); } for (var i = 0; i < attr.length; i++) { var k = attr[i][0], v = attr[i][1]; // TODO: http://forum.dklab.ru/viewtopic.php?p=129059#129059 sv[sv.length] = [k, form.getAttribute(k)]; form.setAttribute(k, v); } if (e.mergeAttributes) { e.mergeAttributes(form, false); } return sv; } // Run submit with delay - for old Opera: it needs some time to create IFRAME. var closure = function() { // Save JsHttpRequest object to new IFRAME. top.JsHttpRequestGlobal = JsHttpRequest; // Disable ALL the form elements. var savedNames = []; if (!wholeFormSending) { for (var i = 0, n = form.elements.length; i < n; i++) { savedNames[i] = form.elements[i].name; form.elements[i].name = ''; } } // Insert hidden fields to the form. var qt = th.queryText.split('&'); for (var i = qt.length - 1; i >= 0; i--) { var pair = qt[i].split('=', 2); var e = d.createElement('INPUT'); e.type = 'hidden'; e.name = unescape(pair[0]); e.value = pair[1] != null? unescape(pair[1]) : ''; form.appendChild(e); } // Change names of along user-passed form elements. for (var i = 0; i < th.queryElem.length; i++) { th.queryElem[i].e.name = th.queryElem[i].name; } // Temporary modify form attributes, submit form, restore attributes back. var sv = setAttributes( form, [ ['action', th.url], ['method', th.method], ['onsubmit', null], ['target', ifname] ] ); form.submit(); setAttributes(form, sv); // Remove generated temporary hidden elements from the top of the form. for (var i = 0; i < qt.length; i++) { // Use "form.firstChild.parentNode", not "form", or IE5 crashes! form.lastChild.parentNode.removeChild(form.lastChild); } // Enable all disabled elements back. if (!wholeFormSending) { for (var i = 0, n = form.elements.length; i < n; i++) { form.elements[i].name = savedNames[i]; } } } JsHttpRequest.setTimeout(closure, 100); // Success. return null; } }} // }}}