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// }}}