twitst4tz

twitter statistics web application
Log | Files | Refs | README | LICENSE

XMLHttpRequest.js (18463B)


      1 /**
      2  * Wrapper for built-in http.js to emulate the browser XMLHttpRequest object.
      3  *
      4  * This can be used with JS designed for browsers to improve reuse of code and
      5  * allow the use of existing libraries.
      6  *
      7  * Usage: include("XMLHttpRequest.js") and use XMLHttpRequest per W3C specs.
      8  *
      9  * @author Dan DeFelippi <dan@driverdan.com>
     10  * @contributor David Ellis <d.f.ellis@ieee.org>
     11  * @license MIT
     12  */
     13 
     14 var fs = require('fs');
     15 var Url = require('url');
     16 var spawn = require('child_process').spawn;
     17 
     18 /**
     19  * Module exports.
     20  */
     21 
     22 module.exports = XMLHttpRequest;
     23 
     24 // backwards-compat
     25 XMLHttpRequest.XMLHttpRequest = XMLHttpRequest;
     26 
     27 /**
     28  * `XMLHttpRequest` constructor.
     29  *
     30  * Supported options for the `opts` object are:
     31  *
     32  *  - `agent`: An http.Agent instance; http.globalAgent may be used; if 'undefined', agent usage is disabled
     33  *
     34  * @param {Object} opts optional "options" object
     35  */
     36 
     37 function XMLHttpRequest(opts) {
     38   "use strict";
     39 
     40   opts = opts || {};
     41 
     42   /**
     43    * Private variables
     44    */
     45   var self = this;
     46   var http = require('http');
     47   var https = require('https');
     48 
     49   // Holds http.js objects
     50   var request;
     51   var response;
     52 
     53   // Request settings
     54   var settings = {};
     55 
     56   // Disable header blacklist.
     57   // Not part of XHR specs.
     58   var disableHeaderCheck = false;
     59 
     60   // Set some default headers
     61   var defaultHeaders = {
     62     "User-Agent": "node-XMLHttpRequest",
     63     "Accept": "*/*"
     64   };
     65 
     66   var headers = Object.assign({}, defaultHeaders);
     67 
     68   // These headers are not user setable.
     69   // The following are allowed but banned in the spec:
     70   // * user-agent
     71   var forbiddenRequestHeaders = [
     72     "accept-charset",
     73     "accept-encoding",
     74     "access-control-request-headers",
     75     "access-control-request-method",
     76     "connection",
     77     "content-length",
     78     "content-transfer-encoding",
     79     "cookie",
     80     "cookie2",
     81     "date",
     82     "expect",
     83     "host",
     84     "keep-alive",
     85     "origin",
     86     "referer",
     87     "te",
     88     "trailer",
     89     "transfer-encoding",
     90     "upgrade",
     91     "via"
     92   ];
     93 
     94   // These request methods are not allowed
     95   var forbiddenRequestMethods = [
     96     "TRACE",
     97     "TRACK",
     98     "CONNECT"
     99   ];
    100 
    101   // Send flag
    102   var sendFlag = false;
    103   // Error flag, used when errors occur or abort is called
    104   var errorFlag = false;
    105 
    106   // Event listeners
    107   var listeners = {};
    108 
    109   /**
    110    * Constants
    111    */
    112 
    113   this.UNSENT = 0;
    114   this.OPENED = 1;
    115   this.HEADERS_RECEIVED = 2;
    116   this.LOADING = 3;
    117   this.DONE = 4;
    118 
    119   /**
    120    * Public vars
    121    */
    122 
    123   // Current state
    124   this.readyState = this.UNSENT;
    125 
    126   // default ready state change handler in case one is not set or is set late
    127   this.onreadystatechange = null;
    128 
    129   // Result & response
    130   this.responseText = "";
    131   this.responseXML = "";
    132   this.status = null;
    133   this.statusText = null;
    134 
    135   /**
    136    * Private methods
    137    */
    138 
    139   /**
    140    * Check if the specified header is allowed.
    141    *
    142    * @param string header Header to validate
    143    * @return boolean False if not allowed, otherwise true
    144    */
    145   var isAllowedHttpHeader = function(header) {
    146     return disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1);
    147   };
    148 
    149   /**
    150    * Check if the specified method is allowed.
    151    *
    152    * @param string method Request method to validate
    153    * @return boolean False if not allowed, otherwise true
    154    */
    155   var isAllowedHttpMethod = function(method) {
    156     return (method && forbiddenRequestMethods.indexOf(method) === -1);
    157   };
    158 
    159   /**
    160    * Public methods
    161    */
    162 
    163   /**
    164    * Open the connection. Currently supports local server requests.
    165    *
    166    * @param string method Connection method (eg GET, POST)
    167    * @param string url URL for the connection.
    168    * @param boolean async Asynchronous connection. Default is true.
    169    * @param string user Username for basic authentication (optional)
    170    * @param string password Password for basic authentication (optional)
    171    */
    172   this.open = function(method, url, async, user, password) {
    173     this.abort();
    174     errorFlag = false;
    175 
    176     // Check for valid request method
    177     if (!isAllowedHttpMethod(method)) {
    178       throw "SecurityError: Request method not allowed";
    179     }
    180 
    181     settings = {
    182       "method": method,
    183       "url": url.toString(),
    184       "async": (typeof async !== "boolean" ? true : async),
    185       "user": user || null,
    186       "password": password || null
    187     };
    188 
    189     setState(this.OPENED);
    190   };
    191 
    192   /**
    193    * Disables or enables isAllowedHttpHeader() check the request. Enabled by default.
    194    * This does not conform to the W3C spec.
    195    *
    196    * @param boolean state Enable or disable header checking.
    197    */
    198   this.setDisableHeaderCheck = function(state) {
    199     disableHeaderCheck = state;
    200   };
    201 
    202   /**
    203    * Sets a header for the request.
    204    *
    205    * @param string header Header name
    206    * @param string value Header value
    207    * @return boolean Header added
    208    */
    209   this.setRequestHeader = function(header, value) {
    210     if (this.readyState != this.OPENED) {
    211       throw "INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN";
    212       return false;
    213     }
    214     if (!isAllowedHttpHeader(header)) {
    215       console.warn('Refused to set unsafe header "' + header + '"');
    216       return false;
    217     }
    218     if (sendFlag) {
    219       throw "INVALID_STATE_ERR: send flag is true";
    220       return false;
    221     }
    222     headers[header] = value;
    223     return true;
    224   };
    225 
    226   /**
    227    * Gets a header from the server response.
    228    *
    229    * @param string header Name of header to get.
    230    * @return string Text of the header or null if it doesn't exist.
    231    */
    232   this.getResponseHeader = function(header) {
    233     if (typeof header === "string"
    234       && this.readyState > this.OPENED
    235       && response.headers[header.toLowerCase()]
    236       && !errorFlag
    237     ) {
    238       return response.headers[header.toLowerCase()];
    239     }
    240 
    241     return null;
    242   };
    243 
    244   /**
    245    * Gets all the response headers.
    246    *
    247    * @return string A string with all response headers separated by CR+LF
    248    */
    249   this.getAllResponseHeaders = function() {
    250     if (this.readyState < this.HEADERS_RECEIVED || errorFlag) {
    251       return "";
    252     }
    253     var result = "";
    254 
    255     for (var i in response.headers) {
    256       // Cookie headers are excluded
    257       if (i !== "set-cookie" && i !== "set-cookie2") {
    258         result += i + ": " + response.headers[i] + "\r\n";
    259       }
    260     }
    261     return result.substr(0, result.length - 2);
    262   };
    263 
    264   /**
    265    * Gets a request header
    266    *
    267    * @param string name Name of header to get
    268    * @return string Returns the request header or empty string if not set
    269    */
    270   this.getRequestHeader = function(name) {
    271     // @TODO Make this case insensitive
    272     if (typeof name === "string" && headers[name]) {
    273       return headers[name];
    274     }
    275 
    276     return "";
    277   };
    278 
    279   /**
    280    * Sends the request to the server.
    281    *
    282    * @param string data Optional data to send as request body.
    283    */
    284   this.send = function(data) {
    285     if (this.readyState != this.OPENED) {
    286       throw "INVALID_STATE_ERR: connection must be opened before send() is called";
    287     }
    288 
    289     if (sendFlag) {
    290       throw "INVALID_STATE_ERR: send has already been called";
    291     }
    292 
    293     var ssl = false, local = false;
    294     var url = Url.parse(settings.url);
    295     var host;
    296     // Determine the server
    297     switch (url.protocol) {
    298       case 'https:':
    299         ssl = true;
    300         // SSL & non-SSL both need host, no break here.
    301       case 'http:':
    302         host = url.hostname;
    303         break;
    304 
    305       case 'file:':
    306         local = true;
    307         break;
    308 
    309       case undefined:
    310       case '':
    311         host = "localhost";
    312         break;
    313 
    314       default:
    315         throw "Protocol not supported.";
    316     }
    317 
    318     // Load files off the local filesystem (file://)
    319     if (local) {
    320       if (settings.method !== "GET") {
    321         throw "XMLHttpRequest: Only GET method is supported";
    322       }
    323 
    324       if (settings.async) {
    325         fs.readFile(url.pathname, 'utf8', function(error, data) {
    326           if (error) {
    327             self.handleError(error);
    328           } else {
    329             self.status = 200;
    330             self.responseText = data;
    331             setState(self.DONE);
    332           }
    333         });
    334       } else {
    335         try {
    336           this.responseText = fs.readFileSync(url.pathname, 'utf8');
    337           this.status = 200;
    338           setState(self.DONE);
    339         } catch(e) {
    340           this.handleError(e);
    341         }
    342       }
    343 
    344       return;
    345     }
    346 
    347     // Default to port 80. If accessing localhost on another port be sure
    348     // to use http://localhost:port/path
    349     var port = url.port || (ssl ? 443 : 80);
    350     // Add query string if one is used
    351     var uri = url.pathname + (url.search ? url.search : '');
    352 
    353     // Set the Host header or the server may reject the request
    354     headers["Host"] = host;
    355     if (!((ssl && port === 443) || port === 80)) {
    356       headers["Host"] += ':' + url.port;
    357     }
    358 
    359     // Set Basic Auth if necessary
    360     if (settings.user) {
    361       if (typeof settings.password == "undefined") {
    362         settings.password = "";
    363       }
    364       var authBuf = new Buffer(settings.user + ":" + settings.password);
    365       headers["Authorization"] = "Basic " + authBuf.toString("base64");
    366     }
    367 
    368     // Set content length header
    369     if (settings.method === "GET" || settings.method === "HEAD") {
    370       data = null;
    371     } else if (data) {
    372       headers["Content-Length"] = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data);
    373 
    374       if (!headers["Content-Type"]) {
    375         headers["Content-Type"] = "text/plain;charset=UTF-8";
    376       }
    377     } else if (settings.method === "POST") {
    378       // For a post with no data set Content-Length: 0.
    379       // This is required by buggy servers that don't meet the specs.
    380       headers["Content-Length"] = 0;
    381     }
    382 
    383     var agent = opts.agent || false;
    384     var options = {
    385       host: host,
    386       port: port,
    387       path: uri,
    388       method: settings.method,
    389       headers: headers,
    390       agent: agent
    391     };
    392 
    393     if (ssl) {
    394       options.pfx = opts.pfx;
    395       options.key = opts.key;
    396       options.passphrase = opts.passphrase;
    397       options.cert = opts.cert;
    398       options.ca = opts.ca;
    399       options.ciphers = opts.ciphers;
    400       options.rejectUnauthorized = opts.rejectUnauthorized;
    401     }
    402 
    403     // Reset error flag
    404     errorFlag = false;
    405 
    406     // Handle async requests
    407     if (settings.async) {
    408       // Use the proper protocol
    409       var doRequest = ssl ? https.request : http.request;
    410 
    411       // Request is being sent, set send flag
    412       sendFlag = true;
    413 
    414       // As per spec, this is called here for historical reasons.
    415       self.dispatchEvent("readystatechange");
    416 
    417       // Handler for the response
    418       var responseHandler = function(resp) {
    419         // Set response var to the response we got back
    420         // This is so it remains accessable outside this scope
    421         response = resp;
    422         // Check for redirect
    423         // @TODO Prevent looped redirects
    424         if (response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307) {
    425           // Change URL to the redirect location
    426           settings.url = response.headers.location;
    427           var url = Url.parse(settings.url);
    428           // Set host var in case it's used later
    429           host = url.hostname;
    430           // Options for the new request
    431           var newOptions = {
    432             hostname: url.hostname,
    433             port: url.port,
    434             path: url.path,
    435             method: response.statusCode === 303 ? 'GET' : settings.method,
    436             headers: headers
    437           };
    438 
    439           if (ssl) {
    440             newOptions.pfx = opts.pfx;
    441             newOptions.key = opts.key;
    442             newOptions.passphrase = opts.passphrase;
    443             newOptions.cert = opts.cert;
    444             newOptions.ca = opts.ca;
    445             newOptions.ciphers = opts.ciphers;
    446             newOptions.rejectUnauthorized = opts.rejectUnauthorized;
    447           }
    448 
    449           // Issue the new request
    450           request = doRequest(newOptions, responseHandler).on('error', errorHandler);
    451           request.end();
    452           // @TODO Check if an XHR event needs to be fired here
    453           return;
    454         }
    455 
    456         if (response && response.setEncoding) {
    457           response.setEncoding("utf8");
    458         }
    459 
    460         setState(self.HEADERS_RECEIVED);
    461         self.status = response.statusCode;
    462 
    463         response.on('data', function(chunk) {
    464           // Make sure there's some data
    465           if (chunk) {
    466             self.responseText += chunk;
    467           }
    468           // Don't emit state changes if the connection has been aborted.
    469           if (sendFlag) {
    470             setState(self.LOADING);
    471           }
    472         });
    473 
    474         response.on('end', function() {
    475           if (sendFlag) {
    476             // The sendFlag needs to be set before setState is called.  Otherwise if we are chaining callbacks
    477             // there can be a timing issue (the callback is called and a new call is made before the flag is reset).
    478             sendFlag = false;
    479             // Discard the 'end' event if the connection has been aborted
    480             setState(self.DONE);
    481           }
    482         });
    483 
    484         response.on('error', function(error) {
    485           self.handleError(error);
    486         });
    487       }
    488 
    489       // Error handler for the request
    490       var errorHandler = function(error) {
    491         self.handleError(error);
    492       }
    493 
    494       // Create the request
    495       request = doRequest(options, responseHandler).on('error', errorHandler);
    496 
    497       // Node 0.4 and later won't accept empty data. Make sure it's needed.
    498       if (data) {
    499         request.write(data);
    500       }
    501 
    502       request.end();
    503 
    504       self.dispatchEvent("loadstart");
    505     } else { // Synchronous
    506       // Create a temporary file for communication with the other Node process
    507       var contentFile = ".node-xmlhttprequest-content-" + process.pid;
    508       var syncFile = ".node-xmlhttprequest-sync-" + process.pid;
    509       fs.writeFileSync(syncFile, "", "utf8");
    510       // The async request the other Node process executes
    511       var execString = "var http = require('http'), https = require('https'), fs = require('fs');"
    512         + "var doRequest = http" + (ssl ? "s" : "") + ".request;"
    513         + "var options = " + JSON.stringify(options) + ";"
    514         + "var responseText = '';"
    515         + "var req = doRequest(options, function(response) {"
    516         + "response.setEncoding('utf8');"
    517         + "response.on('data', function(chunk) {"
    518         + "  responseText += chunk;"
    519         + "});"
    520         + "response.on('end', function() {"
    521         + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8');"
    522         + "fs.unlinkSync('" + syncFile + "');"
    523         + "});"
    524         + "response.on('error', function(error) {"
    525         + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');"
    526         + "fs.unlinkSync('" + syncFile + "');"
    527         + "});"
    528         + "}).on('error', function(error) {"
    529         + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');"
    530         + "fs.unlinkSync('" + syncFile + "');"
    531         + "});"
    532         + (data ? "req.write('" + data.replace(/'/g, "\\'") + "');":"")
    533         + "req.end();";
    534       // Start the other Node Process, executing this string
    535       var syncProc = spawn(process.argv[0], ["-e", execString]);
    536       var statusText;
    537       while(fs.existsSync(syncFile)) {
    538         // Wait while the sync file is empty
    539       }
    540       self.responseText = fs.readFileSync(contentFile, 'utf8');
    541       // Kill the child process once the file has data
    542       syncProc.stdin.end();
    543       // Remove the temporary file
    544       fs.unlinkSync(contentFile);
    545       if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) {
    546         // If the file returned an error, handle it
    547         var errorObj = self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, "");
    548         self.handleError(errorObj);
    549       } else {
    550         // If the file returned okay, parse its data and move to the DONE state
    551         self.status = self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:([0-9]*),.*/, "$1");
    552         self.responseText = self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, "$1");
    553         setState(self.DONE);
    554       }
    555     }
    556   };
    557 
    558   /**
    559    * Called when an error is encountered to deal with it.
    560    */
    561   this.handleError = function(error) {
    562     this.status = 503;
    563     this.statusText = error;
    564     this.responseText = error.stack;
    565     errorFlag = true;
    566     setState(this.DONE);
    567   };
    568 
    569   /**
    570    * Aborts a request.
    571    */
    572   this.abort = function() {
    573     if (request) {
    574       request.abort();
    575       request = null;
    576     }
    577 
    578     headers = Object.assign({}, defaultHeaders);
    579     this.responseText = "";
    580     this.responseXML = "";
    581 
    582     errorFlag = true;
    583 
    584     if (this.readyState !== this.UNSENT
    585         && (this.readyState !== this.OPENED || sendFlag)
    586         && this.readyState !== this.DONE) {
    587       sendFlag = false;
    588       setState(this.DONE);
    589     }
    590     this.readyState = this.UNSENT;
    591   };
    592 
    593   /**
    594    * Adds an event listener. Preferred method of binding to events.
    595    */
    596   this.addEventListener = function(event, callback) {
    597     if (!(event in listeners)) {
    598       listeners[event] = [];
    599     }
    600     // Currently allows duplicate callbacks. Should it?
    601     listeners[event].push(callback);
    602   };
    603 
    604   /**
    605    * Remove an event callback that has already been bound.
    606    * Only works on the matching funciton, cannot be a copy.
    607    */
    608   this.removeEventListener = function(event, callback) {
    609     if (event in listeners) {
    610       // Filter will return a new array with the callback removed
    611       listeners[event] = listeners[event].filter(function(ev) {
    612         return ev !== callback;
    613       });
    614     }
    615   };
    616 
    617   /**
    618    * Dispatch any events, including both "on" methods and events attached using addEventListener.
    619    */
    620   this.dispatchEvent = function(event) {
    621     if (typeof self["on" + event] === "function") {
    622       self["on" + event]();
    623     }
    624     if (event in listeners) {
    625       for (var i = 0, len = listeners[event].length; i < len; i++) {
    626         listeners[event][i].call(self);
    627       }
    628     }
    629   };
    630 
    631   /**
    632    * Changes readyState and calls onreadystatechange.
    633    *
    634    * @param int state New state
    635    */
    636   var setState = function(state) {
    637     if (self.readyState !== state) {
    638       self.readyState = state;
    639 
    640       if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) {
    641         self.dispatchEvent("readystatechange");
    642       }
    643 
    644       if (self.readyState === self.DONE && !errorFlag) {
    645         self.dispatchEvent("load");
    646         // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie)
    647         self.dispatchEvent("loadend");
    648       }
    649     }
    650   };
    651 };