2 * Wrapper for built-in http.js to emulate the browser XMLHttpRequest object.
4 * This can be used with JS designed for browsers to improve reuse of code and
5 * allow the use of existing libraries.
7 * Usage: include("XMLHttpRequest.js") and use XMLHttpRequest per W3C specs.
9 * @author Dan DeFelippi <dan@driverdan.com>
10 * @contributor David Ellis <d.f.ellis@ieee.org>
14 var fs = require('fs');
15 var Url = require('url');
16 var spawn = require('child_process').spawn;
22 module.exports = XMLHttpRequest;
25 XMLHttpRequest.XMLHttpRequest = XMLHttpRequest;
28 * `XMLHttpRequest` constructor.
30 * Supported options for the `opts` object are:
32 * - `agent`: An http.Agent instance; http.globalAgent may be used; if 'undefined', agent usage is disabled
34 * @param {Object} opts optional "options" object
37 function XMLHttpRequest(opts) {
46 var http = require('http');
47 var https = require('https');
49 // Holds http.js objects
56 // Disable header blacklist.
57 // Not part of XHR specs.
58 var disableHeaderCheck = false;
60 // Set some default headers
61 var defaultHeaders = {
62 "User-Agent": "node-XMLHttpRequest",
66 var headers = Object.assign({}, defaultHeaders);
68 // These headers are not user setable.
69 // The following are allowed but banned in the spec:
71 var forbiddenRequestHeaders = [
74 "access-control-request-headers",
75 "access-control-request-method",
78 "content-transfer-encoding",
94 // These request methods are not allowed
95 var forbiddenRequestMethods = [
102 var sendFlag = false;
103 // Error flag, used when errors occur or abort is called
104 var errorFlag = false;
115 this.HEADERS_RECEIVED = 2;
124 this.readyState = this.UNSENT;
126 // default ready state change handler in case one is not set or is set late
127 this.onreadystatechange = null;
130 this.responseText = "";
131 this.responseXML = "";
133 this.statusText = null;
140 * Check if the specified header is allowed.
142 * @param string header Header to validate
143 * @return boolean False if not allowed, otherwise true
145 var isAllowedHttpHeader = function(header) {
146 return disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1);
150 * Check if the specified method is allowed.
152 * @param string method Request method to validate
153 * @return boolean False if not allowed, otherwise true
155 var isAllowedHttpMethod = function(method) {
156 return (method && forbiddenRequestMethods.indexOf(method) === -1);
164 * Open the connection. Currently supports local server requests.
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)
172 this.open = function(method, url, async, user, password) {
176 // Check for valid request method
177 if (!isAllowedHttpMethod(method)) {
178 throw "SecurityError: Request method not allowed";
183 "url": url.toString(),
184 "async": (typeof async !== "boolean" ? true : async),
185 "user": user || null,
186 "password": password || null
189 setState(this.OPENED);
193 * Disables or enables isAllowedHttpHeader() check the request. Enabled by default.
194 * This does not conform to the W3C spec.
196 * @param boolean state Enable or disable header checking.
198 this.setDisableHeaderCheck = function(state) {
199 disableHeaderCheck = state;
203 * Sets a header for the request.
205 * @param string header Header name
206 * @param string value Header value
207 * @return boolean Header added
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";
214 if (!isAllowedHttpHeader(header)) {
215 console.warn('Refused to set unsafe header "' + header + '"');
219 throw "INVALID_STATE_ERR: send flag is true";
222 headers[header] = value;
227 * Gets a header from the server response.
229 * @param string header Name of header to get.
230 * @return string Text of the header or null if it doesn't exist.
232 this.getResponseHeader = function(header) {
233 if (typeof header === "string"
234 && this.readyState > this.OPENED
235 && response.headers[header.toLowerCase()]
238 return response.headers[header.toLowerCase()];
245 * Gets all the response headers.
247 * @return string A string with all response headers separated by CR+LF
249 this.getAllResponseHeaders = function() {
250 if (this.readyState < this.HEADERS_RECEIVED || errorFlag) {
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";
261 return result.substr(0, result.length - 2);
265 * Gets a request header
267 * @param string name Name of header to get
268 * @return string Returns the request header or empty string if not set
270 this.getRequestHeader = function(name) {
271 // @TODO Make this case insensitive
272 if (typeof name === "string" && headers[name]) {
273 return headers[name];
280 * Sends the request to the server.
282 * @param string data Optional data to send as request body.
284 this.send = function(data) {
285 if (this.readyState != this.OPENED) {
286 throw "INVALID_STATE_ERR: connection must be opened before send() is called";
290 throw "INVALID_STATE_ERR: send has already been called";
293 var ssl = false, local = false;
294 var url = Url.parse(settings.url);
296 // Determine the server
297 switch (url.protocol) {
300 // SSL & non-SSL both need host, no break here.
315 throw "Protocol not supported.";
318 // Load files off the local filesystem (file://)
320 if (settings.method !== "GET") {
321 throw "XMLHttpRequest: Only GET method is supported";
324 if (settings.async) {
325 fs.readFile(url.pathname, 'utf8', function(error, data) {
327 self.handleError(error);
330 self.responseText = data;
336 this.responseText = fs.readFileSync(url.pathname, 'utf8');
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 : '');
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;
359 // Set Basic Auth if necessary
361 if (typeof settings.password == "undefined") {
362 settings.password = "";
364 var authBuf = new Buffer(settings.user + ":" + settings.password);
365 headers["Authorization"] = "Basic " + authBuf.toString("base64");
368 // Set content length header
369 if (settings.method === "GET" || settings.method === "HEAD") {
372 headers["Content-Length"] = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data);
374 if (!headers["Content-Type"]) {
375 headers["Content-Type"] = "text/plain;charset=UTF-8";
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;
383 var agent = opts.agent || false;
388 method: settings.method,
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;
406 // Handle async requests
407 if (settings.async) {
408 // Use the proper protocol
409 var doRequest = ssl ? https.request : http.request;
411 // Request is being sent, set send flag
414 // As per spec, this is called here for historical reasons.
415 self.dispatchEvent("readystatechange");
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
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
430 // Options for the new request
432 hostname: url.hostname,
435 method: response.statusCode === 303 ? 'GET' : settings.method,
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;
449 // Issue the new request
450 request = doRequest(newOptions, responseHandler).on('error', errorHandler);
452 // @TODO Check if an XHR event needs to be fired here
456 if (response && response.setEncoding) {
457 response.setEncoding("utf8");
460 setState(self.HEADERS_RECEIVED);
461 self.status = response.statusCode;
463 response.on('data', function(chunk) {
464 // Make sure there's some data
466 self.responseText += chunk;
468 // Don't emit state changes if the connection has been aborted.
470 setState(self.LOADING);
474 response.on('end', function() {
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).
479 // Discard the 'end' event if the connection has been aborted
484 response.on('error', function(error) {
485 self.handleError(error);
489 // Error handler for the request
490 var errorHandler = function(error) {
491 self.handleError(error);
494 // Create the request
495 request = doRequest(options, responseHandler).on('error', errorHandler);
497 // Node 0.4 and later won't accept empty data. Make sure it's needed.
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;"
520 + "response.on('end', function() {"
521 + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8');"
522 + "fs.unlinkSync('" + syncFile + "');"
524 + "response.on('error', function(error) {"
525 + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');"
526 + "fs.unlinkSync('" + syncFile + "');"
528 + "}).on('error', function(error) {"
529 + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');"
530 + "fs.unlinkSync('" + syncFile + "');"
532 + (data ? "req.write('" + data.replace(/'/g, "\\'") + "');":"")
534 // Start the other Node Process, executing this string
535 var syncProc = spawn(process.argv[0], ["-e", execString]);
537 while(fs.existsSync(syncFile)) {
538 // Wait while the sync file is empty
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);
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");
559 * Called when an error is encountered to deal with it.
561 this.handleError = function(error) {
563 this.statusText = error;
564 this.responseText = error.stack;
572 this.abort = function() {
578 headers = Object.assign({}, defaultHeaders);
579 this.responseText = "";
580 this.responseXML = "";
584 if (this.readyState !== this.UNSENT
585 && (this.readyState !== this.OPENED || sendFlag)
586 && this.readyState !== this.DONE) {
590 this.readyState = this.UNSENT;
594 * Adds an event listener. Preferred method of binding to events.
596 this.addEventListener = function(event, callback) {
597 if (!(event in listeners)) {
598 listeners[event] = [];
600 // Currently allows duplicate callbacks. Should it?
601 listeners[event].push(callback);
605 * Remove an event callback that has already been bound.
606 * Only works on the matching funciton, cannot be a copy.
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;
618 * Dispatch any events, including both "on" methods and events attached using addEventListener.
620 this.dispatchEvent = function(event) {
621 if (typeof self["on" + event] === "function") {
622 self["on" + event]();
624 if (event in listeners) {
625 for (var i = 0, len = listeners[event].length; i < len; i++) {
626 listeners[event][i].call(self);
632 * Changes readyState and calls onreadystatechange.
634 * @param int state New state
636 var setState = function(state) {
637 if (self.readyState !== state) {
638 self.readyState = state;
640 if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) {
641 self.dispatchEvent("readystatechange");
644 if (self.readyState === self.DONE && !errorFlag) {
645 self.dispatchEvent("load");
646 // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie)
647 self.dispatchEvent("loadend");