rob@77: /** rob@77: * Wrapper for built-in http.js to emulate the browser XMLHttpRequest object. rob@77: * rob@77: * This can be used with JS designed for browsers to improve reuse of code and rob@77: * allow the use of existing libraries. rob@77: * rob@77: * Usage: include("XMLHttpRequest.js") and use XMLHttpRequest per W3C specs. rob@77: * rob@77: * @author Dan DeFelippi rob@77: * @contributor David Ellis rob@77: * @license MIT rob@77: */ rob@77: rob@77: var Url = require("url") rob@77: , spawn = require("child_process").spawn rob@77: , fs = require('fs'); rob@77: rob@77: exports.XMLHttpRequest = function() { rob@77: /** rob@77: * Private variables rob@77: */ rob@77: var self = this; rob@77: var http = require('http'); rob@77: var https = require('https'); rob@77: rob@77: // Holds http.js objects rob@77: var request; rob@77: var response; rob@77: rob@77: // Request settings rob@77: var settings = {}; rob@77: rob@77: // Disable header blacklist. rob@77: // Not part of XHR specs. rob@77: var disableHeaderCheck = false; rob@77: rob@77: // Set some default headers rob@77: var defaultHeaders = { rob@77: "User-Agent": "node-XMLHttpRequest", rob@77: "Accept": "*/*", rob@77: }; rob@77: rob@77: var headers = defaultHeaders; rob@77: rob@77: // These headers are not user setable. rob@77: // The following are allowed but banned in the spec: rob@77: // * user-agent rob@77: var forbiddenRequestHeaders = [ rob@77: "accept-charset", rob@77: "accept-encoding", rob@77: "access-control-request-headers", rob@77: "access-control-request-method", rob@77: "connection", rob@77: "content-length", rob@77: "content-transfer-encoding", rob@77: "cookie", rob@77: "cookie2", rob@77: "date", rob@77: "expect", rob@77: "host", rob@77: "keep-alive", rob@77: "origin", rob@77: "referer", rob@77: "te", rob@77: "trailer", rob@77: "transfer-encoding", rob@77: "upgrade", rob@77: "via" rob@77: ]; rob@77: rob@77: // These request methods are not allowed rob@77: var forbiddenRequestMethods = [ rob@77: "TRACE", rob@77: "TRACK", rob@77: "CONNECT" rob@77: ]; rob@77: rob@77: // Send flag rob@77: var sendFlag = false; rob@77: // Error flag, used when errors occur or abort is called rob@77: var errorFlag = false; rob@77: rob@77: // Event listeners rob@77: var listeners = {}; rob@77: rob@77: /** rob@77: * Constants rob@77: */ rob@77: rob@77: this.UNSENT = 0; rob@77: this.OPENED = 1; rob@77: this.HEADERS_RECEIVED = 2; rob@77: this.LOADING = 3; rob@77: this.DONE = 4; rob@77: rob@77: /** rob@77: * Public vars rob@77: */ rob@77: rob@77: // Current state rob@77: this.readyState = this.UNSENT; rob@77: rob@77: // default ready state change handler in case one is not set or is set late rob@77: this.onreadystatechange = null; rob@77: rob@77: // Result & response rob@77: this.responseText = ""; rob@77: this.responseXML = ""; rob@77: this.status = null; rob@77: this.statusText = null; rob@77: rob@77: /** rob@77: * Private methods rob@77: */ rob@77: rob@77: /** rob@77: * Check if the specified header is allowed. rob@77: * rob@77: * @param string header Header to validate rob@77: * @return boolean False if not allowed, otherwise true rob@77: */ rob@77: var isAllowedHttpHeader = function(header) { rob@77: return disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1); rob@77: }; rob@77: rob@77: /** rob@77: * Check if the specified method is allowed. rob@77: * rob@77: * @param string method Request method to validate rob@77: * @return boolean False if not allowed, otherwise true rob@77: */ rob@77: var isAllowedHttpMethod = function(method) { rob@77: return (method && forbiddenRequestMethods.indexOf(method) === -1); rob@77: }; rob@77: rob@77: /** rob@77: * Public methods rob@77: */ rob@77: rob@77: /** rob@77: * Open the connection. Currently supports local server requests. rob@77: * rob@77: * @param string method Connection method (eg GET, POST) rob@77: * @param string url URL for the connection. rob@77: * @param boolean async Asynchronous connection. Default is true. rob@77: * @param string user Username for basic authentication (optional) rob@77: * @param string password Password for basic authentication (optional) rob@77: */ rob@77: this.open = function(method, url, async, user, password) { rob@77: this.abort(); rob@77: errorFlag = false; rob@77: rob@77: // Check for valid request method rob@77: if (!isAllowedHttpMethod(method)) { rob@77: throw "SecurityError: Request method not allowed"; rob@77: } rob@77: rob@77: settings = { rob@77: "method": method, rob@77: "url": url.toString(), rob@77: "async": (typeof async !== "boolean" ? true : async), rob@77: "user": user || null, rob@77: "password": password || null rob@77: }; rob@77: rob@77: setState(this.OPENED); rob@77: }; rob@77: rob@77: /** rob@77: * Disables or enables isAllowedHttpHeader() check the request. Enabled by default. rob@77: * This does not conform to the W3C spec. rob@77: * rob@77: * @param boolean state Enable or disable header checking. rob@77: */ rob@77: this.setDisableHeaderCheck = function(state) { rob@77: disableHeaderCheck = state; rob@77: }; rob@77: rob@77: /** rob@77: * Sets a header for the request. rob@77: * rob@77: * @param string header Header name rob@77: * @param string value Header value rob@77: */ rob@77: this.setRequestHeader = function(header, value) { rob@77: if (this.readyState != this.OPENED) { rob@77: throw "INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN"; rob@77: } rob@77: if (!isAllowedHttpHeader(header)) { rob@77: console.warn('Refused to set unsafe header "' + header + '"'); rob@77: return; rob@77: } rob@77: if (sendFlag) { rob@77: throw "INVALID_STATE_ERR: send flag is true"; rob@77: } rob@77: headers[header] = value; rob@77: }; rob@77: rob@77: /** rob@77: * Gets a header from the server response. rob@77: * rob@77: * @param string header Name of header to get. rob@77: * @return string Text of the header or null if it doesn't exist. rob@77: */ rob@77: this.getResponseHeader = function(header) { rob@77: if (typeof header === "string" rob@77: && this.readyState > this.OPENED rob@77: && response.headers[header.toLowerCase()] rob@77: && !errorFlag rob@77: ) { rob@77: return response.headers[header.toLowerCase()]; rob@77: } rob@77: rob@77: return null; rob@77: }; rob@77: rob@77: /** rob@77: * Gets all the response headers. rob@77: * rob@77: * @return string A string with all response headers separated by CR+LF rob@77: */ rob@77: this.getAllResponseHeaders = function() { rob@77: if (this.readyState < this.HEADERS_RECEIVED || errorFlag) { rob@77: return ""; rob@77: } rob@77: var result = ""; rob@77: rob@77: for (var i in response.headers) { rob@77: // Cookie headers are excluded rob@77: if (i !== "set-cookie" && i !== "set-cookie2") { rob@77: result += i + ": " + response.headers[i] + "\r\n"; rob@77: } rob@77: } rob@77: return result.substr(0, result.length - 2); rob@77: }; rob@77: rob@77: /** rob@77: * Gets a request header rob@77: * rob@77: * @param string name Name of header to get rob@77: * @return string Returns the request header or empty string if not set rob@77: */ rob@77: this.getRequestHeader = function(name) { rob@77: // @TODO Make this case insensitive rob@77: if (typeof name === "string" && headers[name]) { rob@77: return headers[name]; rob@77: } rob@77: rob@77: return ""; rob@77: }; rob@77: rob@77: /** rob@77: * Sends the request to the server. rob@77: * rob@77: * @param string data Optional data to send as request body. rob@77: */ rob@77: this.send = function(data) { rob@77: if (this.readyState != this.OPENED) { rob@77: throw "INVALID_STATE_ERR: connection must be opened before send() is called"; rob@77: } rob@77: rob@77: if (sendFlag) { rob@77: throw "INVALID_STATE_ERR: send has already been called"; rob@77: } rob@77: rob@77: var ssl = false, local = false; rob@77: var url = Url.parse(settings.url); rob@77: var host; rob@77: // Determine the server rob@77: switch (url.protocol) { rob@77: case 'https:': rob@77: ssl = true; rob@77: // SSL & non-SSL both need host, no break here. rob@77: case 'http:': rob@77: host = url.hostname; rob@77: break; rob@77: rob@77: case 'file:': rob@77: local = true; rob@77: break; rob@77: rob@77: case undefined: rob@77: case '': rob@77: host = "localhost"; rob@77: break; rob@77: rob@77: default: rob@77: throw "Protocol not supported."; rob@77: } rob@77: rob@77: // Load files off the local filesystem (file://) rob@77: if (local) { rob@77: if (settings.method !== "GET") { rob@77: throw "XMLHttpRequest: Only GET method is supported"; rob@77: } rob@77: rob@77: if (settings.async) { rob@77: fs.readFile(url.pathname, 'utf8', function(error, data) { rob@77: if (error) { rob@77: self.handleError(error); rob@77: } else { rob@77: self.status = 200; rob@77: self.responseText = data; rob@77: setState(self.DONE); rob@77: } rob@77: }); rob@77: } else { rob@77: try { rob@77: this.responseText = fs.readFileSync(url.pathname, 'utf8'); rob@77: this.status = 200; rob@77: setState(self.DONE); rob@77: } catch(e) { rob@77: this.handleError(e); rob@77: } rob@77: } rob@77: rob@77: return; rob@77: } rob@77: rob@77: // Default to port 80. If accessing localhost on another port be sure rob@77: // to use http://localhost:port/path rob@77: var port = url.port || (ssl ? 443 : 80); rob@77: // Add query string if one is used rob@77: var uri = url.pathname + (url.search ? url.search : ''); rob@77: rob@77: // Set the Host header or the server may reject the request rob@77: headers["Host"] = host; rob@77: if (!((ssl && port === 443) || port === 80)) { rob@77: headers["Host"] += ':' + url.port; rob@77: } rob@77: rob@77: // Set Basic Auth if necessary rob@77: if (settings.user) { rob@77: if (typeof settings.password == "undefined") { rob@77: settings.password = ""; rob@77: } rob@77: var authBuf = new Buffer(settings.user + ":" + settings.password); rob@77: headers["Authorization"] = "Basic " + authBuf.toString("base64"); rob@77: } rob@77: rob@77: // Set content length header rob@77: if (settings.method === "GET" || settings.method === "HEAD") { rob@77: data = null; rob@77: } else if (data) { rob@77: headers["Content-Length"] = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data); rob@77: rob@77: if (!headers["Content-Type"]) { rob@77: headers["Content-Type"] = "text/plain;charset=UTF-8"; rob@77: } rob@77: } else if (settings.method === "POST") { rob@77: // For a post with no data set Content-Length: 0. rob@77: // This is required by buggy servers that don't meet the specs. rob@77: headers["Content-Length"] = 0; rob@77: } rob@77: rob@77: var options = { rob@77: host: host, rob@77: port: port, rob@77: path: uri, rob@77: method: settings.method, rob@77: headers: headers, rob@77: agent: false rob@77: }; rob@77: rob@77: // Reset error flag rob@77: errorFlag = false; rob@77: rob@77: // Handle async requests rob@77: if (settings.async) { rob@77: // Use the proper protocol rob@77: var doRequest = ssl ? https.request : http.request; rob@77: rob@77: // Request is being sent, set send flag rob@77: sendFlag = true; rob@77: rob@77: // As per spec, this is called here for historical reasons. rob@77: self.dispatchEvent("readystatechange"); rob@77: rob@77: // Handler for the response rob@77: function responseHandler(resp) { rob@77: // Set response var to the response we got back rob@77: // This is so it remains accessable outside this scope rob@77: response = resp; rob@77: // Check for redirect rob@77: // @TODO Prevent looped redirects rob@77: if (response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307) { rob@77: // Change URL to the redirect location rob@77: settings.url = response.headers.location; rob@77: var url = Url.parse(settings.url); rob@77: // Set host var in case it's used later rob@77: host = url.hostname; rob@77: // Options for the new request rob@77: var newOptions = { rob@77: hostname: url.hostname, rob@77: port: url.port, rob@77: path: url.path, rob@77: method: response.statusCode === 303 ? 'GET' : settings.method, rob@77: headers: headers rob@77: }; rob@77: rob@77: // Issue the new request rob@77: request = doRequest(newOptions, responseHandler).on('error', errorHandler); rob@77: request.end(); rob@77: // @TODO Check if an XHR event needs to be fired here rob@77: return; rob@77: } rob@77: rob@77: response.setEncoding("utf8"); rob@77: rob@77: setState(self.HEADERS_RECEIVED); rob@77: self.status = response.statusCode; rob@77: rob@77: response.on('data', function(chunk) { rob@77: // Make sure there's some data rob@77: if (chunk) { rob@77: self.responseText += chunk; rob@77: } rob@77: // Don't emit state changes if the connection has been aborted. rob@77: if (sendFlag) { rob@77: setState(self.LOADING); rob@77: } rob@77: }); rob@77: rob@77: response.on('end', function() { rob@77: if (sendFlag) { rob@77: // Discard the 'end' event if the connection has been aborted rob@77: setState(self.DONE); rob@77: sendFlag = false; rob@77: } rob@77: }); rob@77: rob@77: response.on('error', function(error) { rob@77: self.handleError(error); rob@77: }); rob@77: } rob@77: rob@77: // Error handler for the request rob@77: function errorHandler(error) { rob@77: self.handleError(error); rob@77: } rob@77: rob@77: // Create the request rob@77: request = doRequest(options, responseHandler).on('error', errorHandler); rob@77: rob@77: // Node 0.4 and later won't accept empty data. Make sure it's needed. rob@77: if (data) { rob@77: request.write(data); rob@77: } rob@77: rob@77: request.end(); rob@77: rob@77: self.dispatchEvent("loadstart"); rob@77: } else { // Synchronous rob@77: // Create a temporary file for communication with the other Node process rob@77: var contentFile = ".node-xmlhttprequest-content-" + process.pid; rob@77: var syncFile = ".node-xmlhttprequest-sync-" + process.pid; rob@77: fs.writeFileSync(syncFile, "", "utf8"); rob@77: // The async request the other Node process executes rob@77: var execString = "var http = require('http'), https = require('https'), fs = require('fs');" rob@77: + "var doRequest = http" + (ssl ? "s" : "") + ".request;" rob@77: + "var options = " + JSON.stringify(options) + ";" rob@77: + "var responseText = '';" rob@77: + "var req = doRequest(options, function(response) {" rob@77: + "response.setEncoding('utf8');" rob@77: + "response.on('data', function(chunk) {" rob@77: + " responseText += chunk;" rob@77: + "});" rob@77: + "response.on('end', function() {" rob@77: + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8');" rob@77: + "fs.unlinkSync('" + syncFile + "');" rob@77: + "});" rob@77: + "response.on('error', function(error) {" rob@77: + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" rob@77: + "fs.unlinkSync('" + syncFile + "');" rob@77: + "});" rob@77: + "}).on('error', function(error) {" rob@77: + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" rob@77: + "fs.unlinkSync('" + syncFile + "');" rob@77: + "});" rob@77: + (data ? "req.write('" + data.replace(/'/g, "\\'") + "');":"") rob@77: + "req.end();"; rob@77: // Start the other Node Process, executing this string rob@77: var syncProc = spawn(process.argv[0], ["-e", execString]); rob@77: var statusText; rob@77: while(fs.existsSync(syncFile)) { rob@77: // Wait while the sync file is empty rob@77: } rob@77: self.responseText = fs.readFileSync(contentFile, 'utf8'); rob@77: // Kill the child process once the file has data rob@77: syncProc.stdin.end(); rob@77: // Remove the temporary file rob@77: fs.unlinkSync(contentFile); rob@77: if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) { rob@77: // If the file returned an error, handle it rob@77: var errorObj = self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, ""); rob@77: self.handleError(errorObj); rob@77: } else { rob@77: // If the file returned okay, parse its data and move to the DONE state rob@77: self.status = self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:([0-9]*),.*/, "$1"); rob@77: self.responseText = self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, "$1"); rob@77: setState(self.DONE); rob@77: } rob@77: } rob@77: }; rob@77: rob@77: /** rob@77: * Called when an error is encountered to deal with it. rob@77: */ rob@77: this.handleError = function(error) { rob@77: this.status = 503; rob@77: this.statusText = error; rob@77: this.responseText = error.stack; rob@77: errorFlag = true; rob@77: setState(this.DONE); rob@77: }; rob@77: rob@77: /** rob@77: * Aborts a request. rob@77: */ rob@77: this.abort = function() { rob@77: if (request) { rob@77: request.abort(); rob@77: request = null; rob@77: } rob@77: rob@77: headers = defaultHeaders; rob@77: this.responseText = ""; rob@77: this.responseXML = ""; rob@77: rob@77: errorFlag = true; rob@77: rob@77: if (this.readyState !== this.UNSENT rob@77: && (this.readyState !== this.OPENED || sendFlag) rob@77: && this.readyState !== this.DONE) { rob@77: sendFlag = false; rob@77: setState(this.DONE); rob@77: } rob@77: this.readyState = this.UNSENT; rob@77: }; rob@77: rob@77: /** rob@77: * Adds an event listener. Preferred method of binding to events. rob@77: */ rob@77: this.addEventListener = function(event, callback) { rob@77: if (!(event in listeners)) { rob@77: listeners[event] = []; rob@77: } rob@77: // Currently allows duplicate callbacks. Should it? rob@77: listeners[event].push(callback); rob@77: }; rob@77: rob@77: /** rob@77: * Remove an event callback that has already been bound. rob@77: * Only works on the matching funciton, cannot be a copy. rob@77: */ rob@77: this.removeEventListener = function(event, callback) { rob@77: if (event in listeners) { rob@77: // Filter will return a new array with the callback removed rob@77: listeners[event] = listeners[event].filter(function(ev) { rob@77: return ev !== callback; rob@77: }); rob@77: } rob@77: }; rob@77: rob@77: /** rob@77: * Dispatch any events, including both "on" methods and events attached using addEventListener. rob@77: */ rob@77: this.dispatchEvent = function(event) { rob@77: if (typeof self["on" + event] === "function") { rob@77: self["on" + event](); rob@77: } rob@77: if (event in listeners) { rob@77: for (var i = 0, len = listeners[event].length; i < len; i++) { rob@77: listeners[event][i].call(self); rob@77: } rob@77: } rob@77: }; rob@77: rob@77: /** rob@77: * Changes readyState and calls onreadystatechange. rob@77: * rob@77: * @param int state New state rob@77: */ rob@77: var setState = function(state) { rob@77: if (self.readyState !== state) { rob@77: self.readyState = state; rob@77: rob@77: if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) { rob@77: self.dispatchEvent("readystatechange"); rob@77: } rob@77: rob@77: if (self.readyState === self.DONE && !errorFlag) { rob@77: self.dispatchEvent("load"); rob@77: // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) rob@77: self.dispatchEvent("loadend"); rob@77: } rob@77: } rob@77: }; rob@77: };