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