1/** 2 * JsHttpRequest: JavaScript "AJAX" data loader (script-xml support only!) 3 * 4 * @license LGPL 5 * @author Dmitry Koterov, http://en.dklab.ru/lib/JsHttpRequest/ 6 * @version 5.x $Id$ 7 */ 8// {{{ 9function JsHttpRequest() { 10 // Standard properties. 11 var t = this; 12 t.onreadystatechange = null; 13 t.readyState = 0; 14 t.responseText = null; 15 t.responseXML = null; 16 t.status = 200; 17 t.statusText = "OK"; 18 // JavaScript response array/hash 19 t.responseJS = null; 20 21 // Additional properties. 22 t.caching = false; // need to use caching? 23 t.loader = null; // loader to use ('form', 'script', 'xml'; null - autodetect) 24 t.session_name = "PHPSESSID"; // set to SID cookie or GET parameter name 25 26 // Internals. 27 t._ldObj = null; // used loader object 28 t._reqHeaders = []; // collected request headers 29 t._openArgs = null; // parameters from open() 30 t._errors = { 31 inv_form_el: 'Invalid FORM element detected: name=%, tag=%', 32 must_be_single_el: 'If used, <form> must be a single HTML element in the list.', 33 js_invalid: 'JavaScript code generated by backend is invalid!\n%', 34 url_too_long: 'Cannot use so long query with GET request (URL is larger than % bytes)', 35 unk_loader: 'Unknown loader: %', 36 no_loaders: 'No loaders registered at all, please check JsHttpRequest.LOADERS array', 37 no_loader_matched: 'Cannot find a loader which may process the request. Notices are:\n%', 38 no_headers: 'Method setRequestHeader() cannot work together with the % loader.' 39 } 40 41 /** 42 * Aborts the request. Behaviour of this function for onreadystatechange() 43 * is identical to IE (most universal and common case). E.g., readyState -> 4 44 * on abort() after send(). 45 */ 46 t.abort = function() { with (this) { 47 if (_ldObj && _ldObj.abort) _ldObj.abort(); 48 _cleanup(); 49 if (readyState == 0) { 50 // start->abort: no change of readyState (IE behaviour) 51 return; 52 } 53 if (readyState == 1 && !_ldObj) { 54 // open->abort: no onreadystatechange call, but change readyState to 0 (IE). 55 // send->abort: change state to 4 (_ldObj is not null when send() is called) 56 readyState = 0; 57 return; 58 } 59 _changeReadyState(4, true); // 4 in IE & FF on abort() call; Opera does not change to 4. 60 }} 61 62 /** 63 * Prepares the object for data loading. 64 */ 65 t.open = function(method, url, asyncFlag, username, password) { with (this){ 66 // Append SID to original URL. Use try...catch for security problems. 67 try { 68 if ( 69 document.location.search.match(new RegExp('[&?]' + session_name + '=([^&?]*)')) 70 || document.cookie.match(new RegExp('(?:;|^)\\s*' + session_name + '=([^;]*)')) 71 ) { 72 url += (url.indexOf('?') >= 0? '&' : '?') + session_name + "=" + this.escape(RegExp.$1); 73 } 74 } catch (e) {} 75 // Store open arguments to hash. 76 _openArgs = { 77 method: (method || '').toUpperCase(), 78 url: url, 79 asyncFlag: asyncFlag, 80 username: username != null? username : '', 81 password: password != null? password : '' 82 } 83 _ldObj = null; 84 _changeReadyState(1, true); // compatibility with XMLHttpRequest 85 return true; 86 }} 87 88 /** 89 * Sends a request to a server. 90 */ 91 t.send = function(content) { 92 if (!this.readyState) { 93 // send without open or after abort: no action (IE behaviour). 94 return; 95 } 96 this._changeReadyState(1, true); // compatibility with XMLHttpRequest 97 this._ldObj = null; 98 99 // Prepare to build QUERY_STRING from query hash. 100 var queryText = []; 101 var queryElem = []; 102 if (!this._hash2query(content, null, queryText, queryElem)) return; 103 104 // Solve the query hashcode & return on cache hit. 105 var hash = null; 106 if (this.caching && !queryElem.length) { 107 hash = this._openArgs.username + ':' + this._openArgs.password + '@' + this._openArgs.url + '|' + queryText + "#" + this._openArgs.method; 108 var cache = JsHttpRequest.CACHE[hash]; 109 if (cache) { 110 this._dataReady(cache[0], cache[1]); 111 return false; 112 } 113 } 114 115 // Try all the loaders. 116 var loader = (this.loader || '').toLowerCase(); 117 if (loader && !JsHttpRequest.LOADERS[loader]) return this._error('unk_loader', loader); 118 var errors = []; 119 var lds = JsHttpRequest.LOADERS; 120 for (var tryLoader in lds) { 121 var ldr = lds[tryLoader].loader; 122 if (!ldr) continue; // exclude possibly derived prototype properties from "for .. in". 123 if (loader && tryLoader != loader) continue; 124 // Create sending context. 125 var ldObj = new ldr(this); 126 JsHttpRequest.extend(ldObj, this._openArgs); 127 JsHttpRequest.extend(ldObj, { 128 queryText: queryText.join('&'), 129 queryElem: queryElem, 130 id: (new Date().getTime()) + "" + JsHttpRequest.COUNT++, 131 hash: hash, 132 span: null 133 }); 134 var error = ldObj.load(); 135 if (!error) { 136 // Save loading script. 137 this._ldObj = ldObj; 138 JsHttpRequest.PENDING[ldObj.id] = this; 139 return true; 140 } 141 if (!loader) { 142 errors[errors.length] = '- ' + tryLoader.toUpperCase() + ': ' + this._l(error); 143 } else { 144 return this._error(error); 145 } 146 } 147 148 // If no loader matched, generate error message. 149 return tryLoader? this._error('no_loader_matched', errors.join('\n')) : this._error('no_loaders'); 150 } 151 152 /** 153 * Returns all response headers (if supported). 154 */ 155 t.getAllResponseHeaders = function() { with (this) { 156 return _ldObj && _ldObj.getAllResponseHeaders? _ldObj.getAllResponseHeaders() : []; 157 }} 158 159 /** 160 * Returns one response header (if supported). 161 */ 162 t.getResponseHeader = function(label) { with (this) { 163 return _ldObj && _ldObj.getResponseHeader? _ldObj.getResponseHeader() : []; 164 }} 165 166 /** 167 * Adds a request header to a future query. 168 */ 169 t.setRequestHeader = function(label, value) { with (this) { 170 _reqHeaders[_reqHeaders.length] = [label, value]; 171 }} 172 173 // 174 // Internal functions. 175 // 176 177 /** 178 * Do all the work when a data is ready. 179 */ 180 t._dataReady = function(text, js) { with (this) { 181 if (caching && _ldObj) JsHttpRequest.CACHE[_ldObj.hash] = [text, js]; 182 if (text !== null || js !== null) { 183 status = 4; 184 responseText = responseXML = text; 185 responseJS = js; 186 } else { 187 status = 500; 188 responseText = responseXML = responseJS = null; 189 } 190 _changeReadyState(2); 191 _changeReadyState(3); 192 _changeReadyState(4); 193 _cleanup(); 194 }} 195 196 /** 197 * Analog of sprintf(), but translates the first parameter by _errors. 198 */ 199 t._l = function(args) { 200 var i = 0, p = 0, msg = this._errors[args[0]]; 201 // Cannot use replace() with a callback, because it is incompatible with IE5. 202 while ((p = msg.indexOf('%', p)) >= 0) { 203 var a = args[++i] + ""; 204 msg = msg.substring(0, p) + a + msg.substring(p + 1, msg.length); 205 p += 1 + a.length; 206 } 207 return msg; 208 } 209 210 /** 211 * Called on error. 212 */ 213 t._error = function(msg) { 214 msg = this._l(typeof(msg) == 'string'? arguments : msg) 215 msg = "JsHttpRequest: " + msg; 216 if (!window.Error) { 217 // Very old browser... 218 throw msg; 219 } else if ((new Error(1, 'test')).description == "test") { 220 // We MUST (!!!) pass 2 parameters to the Error() constructor for IE5. 221 throw new Error(1, msg); 222 } else { 223 // Mozilla does not support two-parameter call style. 224 throw new Error(msg); 225 } 226 } 227 228 /** 229 * Convert hash to QUERY_STRING. 230 * If next value is scalar or hash, push it to queryText. 231 * If next value is form element, push [name, element] to queryElem. 232 */ 233 t._hash2query = function(content, prefix, queryText, queryElem) { 234 if (prefix == null) prefix = ""; 235 if((''+typeof(content)).toLowerCase() == 'object') { 236 var formAdded = false; 237 if (content && content.parentNode && content.parentNode.appendChild && content.tagName && content.tagName.toUpperCase() == 'FORM') { 238 content = { form: content }; 239 } 240 for (var k in content) { 241 var v = content[k]; 242 if (v instanceof Function) continue; 243 var curPrefix = prefix? prefix + '[' + this.escape(k) + ']' : this.escape(k); 244 var isFormElement = v && v.parentNode && v.parentNode.appendChild && v.tagName; 245 if (isFormElement) { 246 var tn = v.tagName.toUpperCase(); 247 if (tn == 'FORM') { 248 // FORM itself is passed. 249 formAdded = true; 250 } else if (tn == 'INPUT' || tn == 'TEXTAREA' || tn == 'SELECT') { 251 // This is a single form elemenent. 252 } else { 253 return this._error('inv_form_el', (v.name||''), v.tagName); 254 } 255 queryElem[queryElem.length] = { name: curPrefix, e: v }; 256 } else if (v instanceof Object) { 257 this._hash2query(v, curPrefix, queryText, queryElem); 258 } else { 259 // We MUST skip NULL values, because there is no method 260 // to pass NULL's via GET or POST request in PHP. 261 if (v === null) continue; 262 queryText[queryText.length] = curPrefix + "=" + this.escape('' + v); 263 } 264 if (formAdded && queryElem.length > 1) { 265 return this._error('must_be_single_el'); 266 } 267 } 268 } else { 269 queryText[queryText.length] = content; 270 } 271 return true; 272 } 273 274 /** 275 * Remove last used script element (clean memory). 276 */ 277 t._cleanup = function() { 278 var ldObj = this._ldObj; 279 if (!ldObj) return; 280 // Mark this loading as aborted. 281 JsHttpRequest.PENDING[ldObj.id] = false; 282 var span = ldObj.span; 283 if (!span) return; 284 ldObj.span = null; 285 var closure = function() { 286 span.parentNode.removeChild(span); 287 } 288 // IE5 crashes on setTimeout(function() {...}, ...) construction! Use tmp variable. 289 JsHttpRequest.setTimeout(closure, 50); 290 } 291 292 /** 293 * Change current readyState and call trigger method. 294 */ 295 t._changeReadyState = function(s, reset) { with (this) { 296 if (reset) { 297 status = statusText = responseJS = null; 298 responseText = ''; 299 } 300 readyState = s; 301 if (onreadystatechange) onreadystatechange(); 302 }} 303 304 /** 305 * JS escape() does not quote '+'. 306 */ 307 t.escape = function(s) { 308 return escape(s).replace(new RegExp('\\+','g'), '%2B'); 309 } 310} 311 312 313// Global library variables. 314JsHttpRequest.COUNT = 0; // unique ID; used while loading IDs generation 315JsHttpRequest.MAX_URL_LEN = 2000; // maximum URL length 316JsHttpRequest.CACHE = {}; // cached data 317JsHttpRequest.PENDING = {}; // pending loadings 318JsHttpRequest.LOADERS = {}; // list of supported data loaders (filled at the bottom of the file) 319JsHttpRequest._dummy = function() {}; // avoid memory leaks 320 321 322/** 323 * These functions are dirty hacks for IE 5.0 which does not increment a 324 * reference counter for an object passed via setTimeout(). So, if this 325 * object (closure function) is out of scope at the moment of timeout 326 * applying, IE 5.0 crashes. 327 */ 328 329/** 330 * Timeout wrappers storage. Used to avoid zeroing of referece counts in IE 5.0. 331 * Please note that you MUST write "window.setTimeout", not "setTimeout", else 332 * IE 5.0 crashes again. Strange, very strange... 333 */ 334JsHttpRequest.TIMEOUTS = { s: window.setTimeout, c: window.clearTimeout }; 335 336/** 337 * Wrapper for IE5 buggy setTimeout. 338 * Use this function instead of a usual setTimeout(). 339 */ 340JsHttpRequest.setTimeout = function(func, dt) { 341 // Always save inside the window object before a call (for FF)! 342 window.JsHttpRequest_tmp = JsHttpRequest.TIMEOUTS.s; 343 if (typeof(func) == "string") { 344 id = window.JsHttpRequest_tmp(func, dt); 345 } else { 346 var id = null; 347 var mediator = function() { 348 func(); 349 delete JsHttpRequest.TIMEOUTS[id]; // remove circular reference 350 } 351 id = window.JsHttpRequest_tmp(mediator, dt); 352 // Store a reference to the mediator function to the global array 353 // (reference count >= 1); use timeout ID as an array key; 354 JsHttpRequest.TIMEOUTS[id] = mediator; 355 } 356 window.JsHttpRequest_tmp = null; // no delete() in IE5 for window 357 return id; 358} 359 360/** 361 * Complimental wrapper for clearTimeout. 362 * Use this function instead of usual clearTimeout(). 363 */ 364JsHttpRequest.clearTimeout = function(id) { 365 window.JsHttpRequest_tmp = JsHttpRequest.TIMEOUTS.c; 366 delete JsHttpRequest.TIMEOUTS[id]; // remove circular reference 367 var r = window.JsHttpRequest_tmp(id); 368 window.JsHttpRequest_tmp = null; // no delete() in IE5 for window 369 return r; 370} 371 372 373/** 374 * Global static function. 375 * Simple interface for most popular use-cases. 376 * You may also pass URLs like "GET url" or "script.GET url". 377 */ 378JsHttpRequest.query = function(url, content, onready, nocache) { 379 var req = new this(); 380 req.caching = !nocache; 381 req.onreadystatechange = function() { 382 if (req.readyState == 4) { 383 onready(req.responseJS, req.responseText); 384 } 385 } 386 var method = null; 387 if (url.match(/^((\w+)\.)?(GET|POST)\s+(.*)/i)) { 388 req.loader = RegExp.$2? RegExp.$2 : null; 389 method = RegExp.$3; 390 url = RegExp.$4; 391 } 392 req.open(method, url, true); 393 req.send(content); 394} 395 396 397/** 398 * Global static function. 399 * Called by server backend script on data load. 400 */ 401JsHttpRequest.dataReady = function(d) { 402 var th = this.PENDING[d.id]; 403 delete this.PENDING[d.id]; 404 if (th) { 405 th._dataReady(d.text, d.js); 406 } else if (th !== false) { 407 throw "dataReady(): unknown pending id: " + d.id; 408 } 409} 410 411 412// Adds all the properties of src to dest. 413JsHttpRequest.extend = function(dest, src) { 414 for (var k in src) dest[k] = src[k]; 415} 416 417/** 418 * Each loader has the following properties which must be initialized: 419 * - method 420 * - url 421 * - asyncFlag (ignored) 422 * - username 423 * - password 424 * - queryText (string) 425 * - queryElem (array) 426 * - id 427 * - hash 428 * - span 429 */ 430 431// }}} 432 433// {{{ script 434// Loader: SCRIPT tag. 435// [+] Most cross-browser. 436// [+] Supports loading from different domains. 437// [-] Only GET method is supported. 438// [-] No uploading support. 439// [-] Backend data cannot be browser-cached. 440// 441JsHttpRequest.LOADERS.script = { loader: function(req) { 442 JsHttpRequest.extend(req._errors, { 443 script_only_get: 'Cannot use SCRIPT loader: it supports only GET method', 444 script_no_form: 'Cannot use SCRIPT loader: direct form elements using and uploading are not implemented' 445 }) 446 447 this.load = function() { 448 // Move GET parameters to the URL itself. 449 if (this.queryText) this.url += (this.url.indexOf('?') >= 0? '&' : '?') + this.queryText; 450 this.url += (this.url.indexOf('?') >= 0? '&' : '?') + 'JsHttpRequest=' + this.id + '-' + 'script'; 451 this.queryText = ''; 452 453 if (!this.method) this.method = 'GET'; 454 if (this.method !== 'GET') return ['script_only_get']; 455 if (this.queryElem.length) return ['script_no_form']; 456 if (this.url.length > JsHttpRequest.MAX_URL_LEN) return ['url_too_long', JsHttpRequest.MAX_URL_LEN]; 457 if (req._reqHeaders.length) return ['no_headers', 'SCRIPT']; 458 459 var th = this, d = document, s = null, b = d.body; 460 if (!window.opera) { 461 // Safari, IE, FF, Opera 7.20. 462 this.span = s = d.createElement('SCRIPT'); 463 var closure = function() { 464 s.language = 'JavaScript'; 465 if (s.setAttribute) s.setAttribute('src', th.url); else s.src = th.url; 466 b.insertBefore(s, b.lastChild); 467 } 468 } else { 469 // Oh shit! Damned stupid Opera 7.23 does not allow to create SCRIPT 470 // element over createElement (in HEAD or BODY section or in nested SPAN - 471 // no matter): it is created deadly, and does not response the href assignment. 472 // So - always create SPAN. 473 this.span = s = d.createElement('SPAN'); 474 s.style.display = 'none'; 475 b.insertBefore(s, b.lastChild); 476 s.innerHTML = 'Workaround for IE.<s'+'cript></' + 'script>'; 477 var closure = function() { 478 s = s.getElementsByTagName('SCRIPT')[0]; // get with timeout! 479 s.language = 'JavaScript'; 480 if (s.setAttribute) s.setAttribute('src', th.url); else s.src = th.url; 481 } 482 } 483 JsHttpRequest.setTimeout(closure, 10); 484 485 // Success. 486 return null; 487 } 488}} 489// }}} 490 491// {{{ xml 492// Loader: XMLHttpRequest or ActiveX. 493// [+] GET and POST methods are supported. 494// [+] Most native and memory-cheap method. 495// [+] Backend data can be browser-cached. 496// [-] Cannot work in IE without ActiveX. 497// [-] No support for loading from different domains. 498// [-] No uploading support. 499// 500JsHttpRequest.LOADERS.xml = { loader: function(req) { 501 JsHttpRequest.extend(req._errors, { 502 xml_no: 'Cannot use XMLHttpRequest or ActiveX loader: not supported', 503 xml_no_diffdom: 'Cannot use XMLHttpRequest to load data from different domain %', 504 xml_no_headers: 'Cannot use XMLHttpRequest loader or ActiveX loader, POST method: headers setting is not supported, needed to work with encodings correctly', 505 xml_no_form_upl: 'Cannot use XMLHttpRequest loader: direct form elements using and uploading are not implemented' 506 }); 507 508 this.load = function() { 509 if (this.queryElem.length) return ['xml_no_form_upl']; 510 511 // XMLHttpRequest (and MS ActiveX'es) cannot work with different domains. 512 if (this.url.match(new RegExp('^([a-z]+)://([^\\/]+)(.*)', 'i'))) { 513 if (RegExp.$2.toLowerCase() == document.location.hostname.toLowerCase()) { 514 this.url = RegExp.$3; 515 } else { 516 return ['xml_no_diffdom', RegExp.$2]; 517 } 518 } 519 520 // Try to obtain a loader. 521 var xr = null; 522 if (window.XMLHttpRequest) { 523 try { xr = new XMLHttpRequest() } catch(e) {} 524 } else if (window.ActiveXObject) { 525 try { xr = new ActiveXObject("Microsoft.XMLHTTP") } catch(e) {} 526 if (!xr) try { xr = new ActiveXObject("Msxml2.XMLHTTP") } catch (e) {} 527 } 528 if (!xr) return ['xml_no']; 529 530 // Loading method detection. We cannot POST if we cannot set "octet-stream" 531 // header, because we need to process the encoded data in the backend manually. 532 var canSetHeaders = window.ActiveXObject || xr.setRequestHeader; 533 if (!this.method) this.method = canSetHeaders? 'POST' : 'GET'; 534 535 // Build & validate the full URL. 536 if (this.method == 'GET') { 537 if (this.queryText) this.url += (this.url.indexOf('?') >= 0? '&' : '?') + this.queryText; 538 this.queryText = ''; 539 if (this.url.length > JsHttpRequest.MAX_URL_LEN) return ['url_too_long', JsHttpRequest.MAX_URL_LEN]; 540 } else if (this.method == 'POST' && !canSetHeaders) { 541 return ['xml_no_headers']; 542 } 543 544 // Add ID to the url if we need to disable the cache. 545 this.url += (this.url.indexOf('?') >= 0? '&' : '?') + 'JsHttpRequest=' + (req.caching? '0' : this.id) + '-xml'; 546 547 // Assign the result handler. 548 var id = this.id; 549 xr.onreadystatechange = function() { 550 if (xr.readyState != 4) return; 551 // Avoid memory leak by removing the closure. 552 xr.onreadystatechange = JsHttpRequest._dummy; 553 req.status = null; 554 try { 555 // In case of abort() call, xr.status is unavailable and generates exception. 556 // But xr.readyState equals to 4 in this case. Stupid behaviour. :-( 557 req.status = xr.status; 558 req.responseText = xr.responseText; 559 } catch (e) {} 560 if (!req.status) return; 561 try { 562 // Prepare generator function & catch syntax errors on this stage. 563 eval('JsHttpRequest._tmp = function(id) { var d = ' + req.responseText + '; d.id = id; JsHttpRequest.dataReady(d); }'); 564 } catch (e) { 565 // Note that FF 2.0 does not throw any error from onreadystatechange handler. 566 return req._error('js_invalid', req.responseText) 567 } 568 // Call associated dataReady() outside the try-catch block 569 // to pass exceptions in onreadystatechange in usual manner. 570 JsHttpRequest._tmp(id); 571 JsHttpRequest._tmp = null; 572 }; 573 574 // Open & send the request. 575 xr.open(this.method, this.url, true, this.username, this.password); 576 if (canSetHeaders) { 577 // Pass pending headers. 578 for (var i = 0; i < req._reqHeaders.length; i++) { 579 xr.setRequestHeader(req._reqHeaders[i][0], req._reqHeaders[i][1]); 580 } 581 // Set non-default Content-type. We cannot use 582 // "application/x-www-form-urlencoded" here, because 583 // in PHP variable HTTP_RAW_POST_DATA is accessible only when 584 // enctype is not default (e.g., "application/octet-stream" 585 // is a good start). We parse POST data manually in backend 586 // library code. Note that Safari sets by default "x-www-form-urlencoded" 587 // header, but FF sets "text/xml" by default. 588 xr.setRequestHeader('Content-Type', 'application/octet-stream'); 589 } 590 xr.send(this.queryText); 591 592 // No SPAN is used for this loader. 593 this.span = null; 594 this.xr = xr; // save for later usage on abort() 595 596 // Success. 597 return null; 598 } 599 600 // Override req.getAllResponseHeaders method. 601 this.getAllResponseHeaders = function() { 602 return this.xr.getAllResponseHeaders(); 603 } 604 605 // Override req.getResponseHeader method. 606 this.getResponseHeader = function(label) { 607 return this.xr.getResponseHeader(label); 608 } 609 610 this.abort = function() { 611 this.xr.abort(); 612 this.xr = null; 613 } 614}} 615// }}}