diff --git a/autotest.watchr b/autotest.watchr index 5324db6..156165d 100644 --- a/autotest.watchr +++ b/autotest.watchr @@ -1,8 +1,16 @@ def run_all_tests puts `clear` + + Dir["tests/*.js"].each do |file| + puts `node #{file}` + end +=begin puts `node tests/test-constants.js` puts `node tests/test-headers.js` - puts `node tests/test-request.js` + puts `node tests/test-request-methods.js` + puts `node tests/test-request-protocols.js` + puts `node tests/test-streaming.js` +=end end -watch('.*.js') { run_all_tests } +watch('.*.js') {run_all_tests} run_all_tests diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 4893913..6e940e4 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -14,607 +14,720 @@ var Url = require("url"); var spawn = require("child_process").spawn; var fs = require("fs"); +const crypto = require('crypto'); -exports.XMLHttpRequest = function() { - "use strict"; - - /** - * Private variables - */ - var self = this; - var http = require("http"); - var https = require("https"); - - // Holds http.js objects - var request; - var response; - - // Request settings - var settings = {}; - - // Disable header blacklist. - // Not part of XHR specs. - var disableHeaderCheck = false; - - // Set some default headers - var defaultHeaders = { - "User-Agent": "node-XMLHttpRequest", - "Accept": "*/*", - }; - - var headers = {}; - var headersCase = {}; - - // These headers are not user setable. - // The following are allowed but banned in the spec: - // * user-agent - var forbiddenRequestHeaders = [ - "accept-charset", - "accept-encoding", - "access-control-request-headers", - "access-control-request-method", - "connection", - "content-length", - "content-transfer-encoding", - "cookie", - "cookie2", - "date", - "expect", - "host", - "keep-alive", - "origin", - "referer", - "te", - "trailer", - "transfer-encoding", - "upgrade", - "via" - ]; - - // These request methods are not allowed - var forbiddenRequestMethods = [ - "TRACE", - "TRACK", - "CONNECT" - ]; - - // Send flag - var sendFlag = false; - // Error flag, used when errors occur or abort is called - var errorFlag = false; - - // Event listeners - var listeners = {}; - - /** - * Constants - */ - - this.UNSENT = 0; - this.OPENED = 1; - this.HEADERS_RECEIVED = 2; - this.LOADING = 3; - this.DONE = 4; - - /** - * Public vars - */ - - // Current state - this.readyState = this.UNSENT; - - // default ready state change handler in case one is not set or is set late - this.onreadystatechange = null; - - // Result & response - this.responseText = ""; - this.responseXML = ""; - this.status = null; - this.statusText = null; - - // Whether cross-site Access-Control requests should be made using - // credentials such as cookies or authorization headers - this.withCredentials = false; - - /** - * Private methods - */ - - /** - * Check if the specified header is allowed. - * - * @param string header Header to validate - * @return boolean False if not allowed, otherwise true - */ - var isAllowedHttpHeader = function(header) { - return disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1); - }; - - /** - * Check if the specified method is allowed. - * - * @param string method Request method to validate - * @return boolean False if not allowed, otherwise true - */ - var isAllowedHttpMethod = function(method) { - return (method && forbiddenRequestMethods.indexOf(method) === -1); - }; - - /** - * Public methods - */ - - /** - * Open the connection. Currently supports local server requests. - * - * @param string method Connection method (eg GET, POST) - * @param string url URL for the connection. - * @param boolean async Asynchronous connection. Default is true. - * @param string user Username for basic authentication (optional) - * @param string password Password for basic authentication (optional) - */ - this.open = function(method, url, async, user, password) { - this.abort(); - errorFlag = false; - - // Check for valid request method - if (!isAllowedHttpMethod(method)) { - throw new Error("SecurityError: Request method not allowed"); - } +function clone(obj) { + return JSON.parse(JSON.stringify(obj)); +} - settings = { - "method": method, - "url": url.toString(), - "async": (typeof async !== "boolean" ? true : async), - "user": user || null, - "password": password || null - }; +var isCachingRequests = false, + requestCache = []; - setState(this.OPENED); - }; - - /** - * Disables or enables isAllowedHttpHeader() check the request. Enabled by default. - * This does not conform to the W3C spec. - * - * @param boolean state Enable or disable header checking. - */ - this.setDisableHeaderCheck = function(state) { - disableHeaderCheck = state; - }; - - /** - * Sets a header for the request or appends the value if one is already set. - * - * @param string header Header name - * @param string value Header value - */ - this.setRequestHeader = function(header, value) { - if (this.readyState !== this.OPENED) { - throw new Error("INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN"); +function loadCache(cacheData) { + if (Array.isArray(cacheData)) { + requestCache = cacheData; + } else { + throw ("Invalid recorded sessions data"); } - if (!isAllowedHttpHeader(header)) { - console.warn("Refused to set unsafe header \"" + header + "\""); - return; - } - if (sendFlag) { - throw new Error("INVALID_STATE_ERR: send flag is true"); - } - header = headersCase[header.toLowerCase()] || header; - headersCase[header.toLowerCase()] = header; - headers[header] = headers[header] ? headers[header] + ', ' + value : value; - }; - - /** - * Gets a header from the server response. - * - * @param string header Name of header to get. - * @return string Text of the header or null if it doesn't exist. - */ - this.getResponseHeader = function(header) { - if (typeof header === "string" - && this.readyState > this.OPENED - && response - && response.headers - && response.headers[header.toLowerCase()] - && !errorFlag - ) { - return response.headers[header.toLowerCase()]; +} + +function generateHashForSession(sessionData) { + let hash = crypto.createHash('md5'); + hash.update([sessionData.method, sessionData.url, sessionData.async, JSON.stringify(sessionData.headers), JSON.stringify(sessionData.requestData)].join('')); + return hash.digest("hex"); +} + +exports.setCaching = function(isCaching) { + if (typeof(isCaching) === "boolean") { + isCachingRequests = isCaching; + if (!isCaching) { + requestCache.length = 0; + } } +} + +exports.getCache = function() { + return requestCache; +} + +exports.loadCacheFile = function(fileName) { + return new Promise((resolve, reject) => { + fs.readFile(fileName, (err, data) => { + let sessionData; + if (err) { + reject(err); + } else { + sessionData = JSON.parse(data.toString('utf-8')); + loadCache(sessionData); + resolve(); + } + }); + }); +} - return null; - }; - - /** - * Gets all the response headers. - * - * @return string A string with all response headers separated by CR+LF - */ - this.getAllResponseHeaders = function() { - if (this.readyState < this.HEADERS_RECEIVED || errorFlag) { - return ""; - } - var result = ""; +exports.loadCache = loadCache; - for (var i in response.headers) { - // Cookie headers are excluded - if (i !== "set-cookie" && i !== "set-cookie2") { - result += i + ": " + response.headers[i] + "\r\n"; - } - } - return result.substr(0, result.length - 2); - }; - - /** - * Gets a request header - * - * @param string name Name of header to get - * @return string Returns the request header or empty string if not set - */ - this.getRequestHeader = function(name) { - if (typeof name === "string" && headersCase[name.toLowerCase()]) { - return headers[headersCase[name.toLowerCase()]]; - } +exports.XMLHttpRequest = function() { + "use strict"; + + /** + * Private variables + */ + var self = this; + var http = require("http"); + var https = require("https"); + + // Holds http.js objects + var request; + var response; + + // Request settings + var settings = {}; + + // Disable header blacklist. + // Not part of XHR specs. + var disableHeaderCheck = false; + + // Set some default headers + var defaultHeaders = { + "User-Agent": "node-XMLHttpRequest", + "Accept": "*/*", + }; - return ""; - }; - - /** - * Sends the request to the server. - * - * @param string data Optional data to send as request body. - */ - this.send = function(data) { - if (this.readyState !== this.OPENED) { - throw new Error("INVALID_STATE_ERR: connection must be opened before send() is called"); - } + var headers = {}; + var headersCase = {}; + + // These headers are not user setable. + // The following are allowed but banned in the spec: + // * user-agent + var forbiddenRequestHeaders = [ + "accept-charset", + "accept-encoding", + "access-control-request-headers", + "access-control-request-method", + "connection", + "content-length", + "content-transfer-encoding", + "cookie", + "cookie2", + "date", + "expect", + "host", + "keep-alive", + "origin", + "referer", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "via" + ]; + + // These request methods are not allowed + var forbiddenRequestMethods = [ + "TRACE", + "TRACK", + "CONNECT" + ]; + + // Send flag + var sendFlag = false; + // Error flag, used when errors occur or abort is called + var errorFlag = false; + + // Event listeners + var listeners = {}; + + /** + * Constants + */ + + this.UNSENT = 0; + this.OPENED = 1; + this.HEADERS_RECEIVED = 2; + this.LOADING = 3; + this.DONE = 4; + + /** + * Public vars + */ + + // Current state + this.readyState = this.UNSENT; - if (sendFlag) { - throw new Error("INVALID_STATE_ERR: send has already been called"); - } + // default ready state change handler in case one is not set or is set late + this.onreadystatechange = null; - var ssl = false, local = false; - var url = Url.parse(settings.url); - var host; - // Determine the server - switch (url.protocol) { - case "https:": - ssl = true; - // SSL & non-SSL both need host, no break here. - case "http:": - host = url.hostname; - break; - - case "file:": - local = true; - break; - - case undefined: - case null: - case "": - host = "localhost"; - break; - - default: - throw new Error("Protocol not supported."); - } + // Result & response + this.responseText = ""; + this.responseXML = ""; + this.status = null; + this.statusText = null; + + // Whether cross-site Access-Control requests should be made using + // credentials such as cookies or authorization headers + this.withCredentials = false; + + // for session recordings + this.currentSession = null; + + /** + * Private methods + */ + + /** + * Check if the specified header is allowed. + * + * @param string header Header to validate + * @return boolean False if not allowed, otherwise true + */ + var isAllowedHttpHeader = function(header) { + return disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1); + }; - // Load files off the local filesystem (file://) - if (local) { - if (settings.method !== "GET") { - throw new Error("XMLHttpRequest: Only GET method is supported"); - } - - if (settings.async) { - fs.readFile(url.pathname, "utf8", function(error, data) { - if (error) { - self.handleError(error); - } else { - self.status = 200; - self.responseText = data; - setState(self.DONE); - } - }); - } else { - try { - this.responseText = fs.readFileSync(url.pathname, "utf8"); - this.status = 200; - setState(self.DONE); - } catch(e) { - this.handleError(e); + /** + * Check if the specified method is allowed. + * + * @param string method Request method to validate + * @return boolean False if not allowed, otherwise true + */ + var isAllowedHttpMethod = function(method) { + return (method && forbiddenRequestMethods.indexOf(method) === -1); + }; + + /** + * Public methods + */ + + /** + * Open the connection. Currently supports local server requests. + * + * @param string method Connection method (eg GET, POST) + * @param string url URL for the connection. + * @param boolean async Asynchronous connection. Default is true. + * @param string user Username for basic authentication (optional) + * @param string password Password for basic authentication (optional) + */ + this.open = function(method, url, async, user, password) { + this.abort(); + errorFlag = false; + + // Check for valid request method + if (!isAllowedHttpMethod(method)) { + throw new Error("SecurityError: Request method not allowed"); } - } - return; - } + settings = { + "method": method, + "url": url.toString(), + "async": (typeof async !== "boolean" ? true : async), + "user": user || null, + "password": password || null + }; + + if (isCachingRequests) { + this.currentSession = clone(settings); + this.currentSession.headers = headers; + } - // Default to port 80. If accessing localhost on another port be sure - // to use http://localhost:port/path - var port = url.port || (ssl ? 443 : 80); - // Add query string if one is used - var uri = url.pathname + (url.search ? url.search : ""); - - // Set the defaults if they haven't been set - for (var name in defaultHeaders) { - if (!headersCase[name.toLowerCase()]) { - headers[name] = defaultHeaders[name]; - } - } + setState(this.OPENED); + }; - // Set the Host header or the server may reject the request - headers.Host = host; - if (!((ssl && port === 443) || port === 80)) { - headers.Host += ":" + url.port; - } + /** + * Disables or enables isAllowedHttpHeader() check the request. Enabled by default. + * This does not conform to the W3C spec. + * + * @param boolean state Enable or disable header checking. + */ + this.setDisableHeaderCheck = function(state) { + disableHeaderCheck = state; + }; - // Set Basic Auth if necessary - if (settings.user) { - if (typeof settings.password === "undefined") { - settings.password = ""; - } - var authBuf = new Buffer(settings.user + ":" + settings.password); - headers.Authorization = "Basic " + authBuf.toString("base64"); - } + /** + * Sets a header for the request or appends the value if one is already set. + * + * @param string header Header name + * @param string value Header value + */ + this.setRequestHeader = function(header, value) { + if (this.readyState !== this.OPENED) { + throw new Error("INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN"); + } + if (!isAllowedHttpHeader(header)) { + console.warn("Refused to set unsafe header \"" + header + "\""); + return; + } + if (sendFlag) { + throw new Error("INVALID_STATE_ERR: send flag is true"); + } + header = headersCase[header.toLowerCase()] || header; + headersCase[header.toLowerCase()] = header; + headers[header] = headers[header] ? headers[header] + ', ' + value : value; + }; - // Set content length header - if (settings.method === "GET" || settings.method === "HEAD") { - data = null; - } else if (data) { - headers["Content-Length"] = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data); - - if (!headers["Content-Type"]) { - headers["Content-Type"] = "text/plain;charset=UTF-8"; - } - } else if (settings.method === "POST") { - // For a post with no data set Content-Length: 0. - // This is required by buggy servers that don't meet the specs. - headers["Content-Length"] = 0; - } + /** + * Gets a header from the server response. + * + * @param string header Name of header to get. + * @return string Text of the header or null if it doesn't exist. + */ + this.getResponseHeader = function(header) { + if (typeof header === "string" && + this.readyState > this.OPENED && + response && + response.headers && + response.headers[header.toLowerCase()] && + !errorFlag + ) { + return response.headers[header.toLowerCase()]; + } + + return null; + }; + + /** + * Gets all the response headers. + * + * @return string A string with all response headers separated by CR+LF + */ + this.getAllResponseHeaders = function() { + if (this.readyState < this.HEADERS_RECEIVED || errorFlag) { + return ""; + } + var result = ""; + + for (var i in response.headers) { + // Cookie headers are excluded + if (i !== "set-cookie" && i !== "set-cookie2") { + result += i + ": " + response.headers[i] + "\r\n"; + } + } + return result.substr(0, result.length - 2); + }; - var options = { - host: host, - port: port, - path: uri, - method: settings.method, - headers: headers, - agent: false, - withCredentials: self.withCredentials + /** + * Gets a request header + * + * @param string name Name of header to get + * @return string Returns the request header or empty string if not set + */ + this.getRequestHeader = function(name) { + if (typeof name === "string" && headersCase[name.toLowerCase()]) { + return headers[headersCase[name.toLowerCase()]]; + } + + return ""; }; - // Reset error flag - errorFlag = false; - - // Handle async requests - if (settings.async) { - // Use the proper protocol - var doRequest = ssl ? https.request : http.request; - - // Request is being sent, set send flag - sendFlag = true; - - // As per spec, this is called here for historical reasons. - self.dispatchEvent("readystatechange"); - - // Handler for the response - var responseHandler = function responseHandler(resp) { - // Set response var to the response we got back - // This is so it remains accessable outside this scope - response = resp; - // Check for redirect - // @TODO Prevent looped redirects - if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307) { - // Change URL to the redirect location - settings.url = response.headers.location; - var url = Url.parse(settings.url); - // Set host var in case it's used later - host = url.hostname; - // Options for the new request - var newOptions = { - hostname: url.hostname, - port: url.port, - path: url.path, - method: response.statusCode === 303 ? "GET" : settings.method, + /** + * Sends the request to the server. + * + * @param string data Optional data to send as request body. + */ + this.send = function(data) { + if (this.readyState !== this.OPENED) { + throw new Error("INVALID_STATE_ERR: connection must be opened before send() is called"); + } + + if (sendFlag) { + throw new Error("INVALID_STATE_ERR: send has already been called"); + } + + var ssl = false, + local = false; + var url = Url.parse(settings.url); + var host; + // Determine the server + switch (url.protocol) { + case "https:": + ssl = true; + // SSL & non-SSL both need host, no break here. + case "http:": + host = url.hostname; + break; + + case "file:": + local = true; + break; + + case undefined: + case null: + case "": + host = "localhost"; + break; + + default: + throw new Error("Protocol not supported."); + } + + // Load files off the local filesystem (file://) + if (local) { + if (settings.method !== "GET") { + throw new Error("XMLHttpRequest: Only GET method is supported"); + } + + if (settings.async) { + fs.readFile(url.pathname, "utf8", function(error, data) { + if (error) { + self.handleError(error); + } else { + self.status = 200; + self.responseText = data; + setState(self.DONE); + } + }); + } else { + try { + this.responseText = fs.readFileSync(url.pathname, "utf8"); + this.status = 200; + setState(self.DONE); + } catch (e) { + this.handleError(e); + } + } + + return; + } + + // Default to port 80. If accessing localhost on another port be sure + // to use http://localhost:port/path + var port = url.port || (ssl ? 443 : 80); + // Add query string if one is used + var uri = url.pathname + (url.search ? url.search : ""); + + // Set the defaults if they haven't been set + for (var name in defaultHeaders) { + if (!headersCase[name.toLowerCase()]) { + headers[name] = defaultHeaders[name]; + } + } + + // Set the Host header or the server may reject the request + headers.Host = host; + if (!((ssl && port === 443) || port === 80)) { + headers.Host += ":" + url.port; + } + + // Set Basic Auth if necessary + if (settings.user) { + if (typeof settings.password === "undefined") { + settings.password = ""; + } + var authBuf = new Buffer(settings.user + ":" + settings.password); + headers.Authorization = "Basic " + authBuf.toString("base64"); + } + + // Set content length header + if (settings.method === "GET" || settings.method === "HEAD") { + data = null; + } else if (data) { + headers["Content-Length"] = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data); + + if (!headers["Content-Type"]) { + headers["Content-Type"] = "text/plain;charset=UTF-8"; + } + } else if (settings.method === "POST") { + // For a post with no data set Content-Length: 0. + // This is required by buggy servers that don't meet the specs. + headers["Content-Length"] = 0; + } + + var options = { + host: host, + port: port, + path: uri, + method: settings.method, headers: headers, + agent: false, withCredentials: self.withCredentials - }; + }; - // Issue the new request - request = doRequest(newOptions, responseHandler).on("error", errorHandler); - request.end(); - // @TODO Check if an XHR event needs to be fired here - return; + // Reset error flag + errorFlag = false; + + if (isCachingRequests) { + this.currentSession.requestData = data; } - response.setEncoding("utf8"); + let sessionHash, + sessionData; - setState(self.HEADERS_RECEIVED); - self.status = response.statusCode; + if (requestCache.length > 0) { + sessionHash = generateHashForSession(this.currentSession); + sessionData = requestCache.find(data => data.hash === sessionHash); + } - response.on("data", function(chunk) { - // Make sure there's some data - if (chunk) { - self.responseText += chunk; - } - // Don't emit state changes if the connection has been aborted. - if (sendFlag) { - setState(self.LOADING); - } - }); + if (sessionData !== undefined) { + this.currentSession = sessionData; + this.responseText = this.currentSession.responseText; + this.responseXML = this.currentSession.responseXML; + this.status = this.currentSession.status; + this.statusText = this.currentSession.statusText; + response = this.currentSession.response; + this.dispatchEvent("loadstart"); + this.dispatchEvent("readystatechange"); + if (settings.async) { + // make sure setState is called in a new stack to simulate async operation + setTimeout(() => { + setState(self.DONE); + }, 0) + } else { + setState(self.DONE); + } + + } else { + // Handle async requests + if (settings.async) { + // Use the proper protocol + var doRequest = ssl ? https.request : http.request; + + // Request is being sent, set send flag + sendFlag = true; + + // As per spec, this is called here for historical reasons. + self.dispatchEvent("readystatechange"); + + // Handler for the response + var responseHandler = function responseHandler(resp) { + // Set response var to the response we got back + // This is so it remains accessable outside this scope + response = resp; + self.currentSession.response = { + contentType: response.contentType, + headers: response.headers + }; + // Check for redirect + // @TODO Prevent looped redirects + if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307) { + // Change URL to the redirect location + settings.url = response.headers.location; + var url = Url.parse(settings.url); + // Set host var in case it's used later + host = url.hostname; + // Options for the new request + var newOptions = { + hostname: url.hostname, + port: url.port, + path: url.path, + method: response.statusCode === 303 ? "GET" : settings.method, + headers: headers, + withCredentials: self.withCredentials + }; + + // Issue the new request + request = doRequest(newOptions, responseHandler).on("error", errorHandler); + request.end(); + // @TODO Check if an XHR event needs to be fired here + return; + } + + response.setEncoding("utf8"); + + setState(self.HEADERS_RECEIVED); + self.status = response.statusCode; + + response.on("data", function(chunk) { + // Make sure there's some data + if (chunk) { + self.responseText += chunk; + } + // Don't emit state changes if the connection has been aborted. + if (sendFlag) { + setState(self.LOADING); + } + }); + + response.on("end", function() { + if (sendFlag) { + // Discard the end event if the connection has been aborted + setState(self.DONE); + sendFlag = false; + } + }); + + response.on("error", function(error) { + self.handleError(error); + }); + }; + + // Error handler for the request + var errorHandler = function errorHandler(error) { + self.handleError(error); + }; + + // Create the request + request = doRequest(options, responseHandler).on("error", errorHandler); + + // Node 0.4 and later won't accept empty data. Make sure it's needed. + if (data) { + request.write(data); + } + + request.end(); + + self.dispatchEvent("loadstart"); + } else { // Synchronous + // Create a temporary file for communication with the other Node process + var contentFile = ".node-xmlhttprequest-content-" + process.pid; + var syncFile = ".node-xmlhttprequest-sync-" + process.pid; + fs.writeFileSync(syncFile, "", "utf8"); + // The async request the other Node process executes + var execString = "var http = require('http'), https = require('https'), fs = require('fs');" + + "var doRequest = http" + (ssl ? "s" : "") + ".request;" + + "var options = " + JSON.stringify(options) + ";" + + "var responseText = '';" + + "var req = doRequest(options, function(response) {" + + "response.setEncoding('utf8');" + + "response.on('data', function(chunk) {" + + " responseText += chunk;" + + "});" + + "response.on('end', function() {" + + "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText}}), 'utf8');" + + "fs.unlinkSync('" + syncFile + "');" + + "});" + + "response.on('error', function(error) {" + + "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: error}), 'utf8');" + + "fs.unlinkSync('" + syncFile + "');" + + "});" + + "}).on('error', function(error) {" + + "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: error}), 'utf8');" + + "fs.unlinkSync('" + syncFile + "');" + + "});" + + (data ? "req.write('" + JSON.stringify(data).slice(1, -1).replace(/'/g, "\\'") + "');" : "") + + "req.end();"; + // Start the other Node Process, executing this string + var syncProc = spawn(process.argv[0], ["-e", execString]); + while (fs.existsSync(syncFile)) { + // Wait while the sync file is empty + } + var resp = JSON.parse(fs.readFileSync(contentFile, 'utf8')); + // Kill the child process once the file has data + syncProc.stdin.end(); + // Remove the temporary file + fs.unlinkSync(contentFile); + + if (resp.err) { + self.handleError(resp.err); + } else { + response = resp.data; + self.currentSession.response = { + contentType: response.contentType, + headers: response.headers + }; + self.status = resp.data.statusCode; + self.responseText = resp.data.text; + setState(self.DONE); + } + } + } + }; + + /** + * Called when an error is encountered to deal with it. + */ + this.handleError = function(error) { + this.status = 0; + this.statusText = error; + this.responseText = error.stack; + errorFlag = true; + setState(this.DONE); + this.dispatchEvent('error'); + }; + + /** + * Aborts a request. + */ + this.abort = function() { + if (request) { + request.abort(); + request = null; + } - response.on("end", function() { - if (sendFlag) { - // Discard the end event if the connection has been aborted - setState(self.DONE); + headers = defaultHeaders; + this.status = 0; + this.responseText = ""; + this.responseXML = ""; + + errorFlag = true; + + if (this.readyState !== this.UNSENT && + (this.readyState !== this.OPENED || sendFlag) && + this.readyState !== this.DONE) { sendFlag = false; - } - }); + setState(this.DONE); + } + this.readyState = this.UNSENT; + this.dispatchEvent('abort'); + }; - response.on("error", function(error) { - self.handleError(error); - }); - }; - - // Error handler for the request - var errorHandler = function errorHandler(error) { - self.handleError(error); - }; - - // Create the request - request = doRequest(options, responseHandler).on("error", errorHandler); - - // Node 0.4 and later won't accept empty data. Make sure it's needed. - if (data) { - request.write(data); - } - - request.end(); - - self.dispatchEvent("loadstart"); - } else { // Synchronous - // Create a temporary file for communication with the other Node process - var contentFile = ".node-xmlhttprequest-content-" + process.pid; - var syncFile = ".node-xmlhttprequest-sync-" + process.pid; - fs.writeFileSync(syncFile, "", "utf8"); - // The async request the other Node process executes - var execString = "var http = require('http'), https = require('https'), fs = require('fs');" - + "var doRequest = http" + (ssl ? "s" : "") + ".request;" - + "var options = " + JSON.stringify(options) + ";" - + "var responseText = '';" - + "var req = doRequest(options, function(response) {" - + "response.setEncoding('utf8');" - + "response.on('data', function(chunk) {" - + " responseText += chunk;" - + "});" - + "response.on('end', function() {" - + "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText}}), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + "response.on('error', function(error) {" - + "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: error}), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + "}).on('error', function(error) {" - + "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: error}), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + (data ? "req.write('" + JSON.stringify(data).slice(1,-1).replace(/'/g, "\\'") + "');":"") - + "req.end();"; - // Start the other Node Process, executing this string - var syncProc = spawn(process.argv[0], ["-e", execString]); - while(fs.existsSync(syncFile)) { - // Wait while the sync file is empty - } - var resp = JSON.parse(fs.readFileSync(contentFile, 'utf8')); - // Kill the child process once the file has data - syncProc.stdin.end(); - // Remove the temporary file - fs.unlinkSync(contentFile); - - if (resp.err) { - self.handleError(resp.err); - } else { - response = resp.data; - self.status = resp.data.statusCode; - self.responseText = resp.data.text; - setState(self.DONE); - } - } - }; - - /** - * Called when an error is encountered to deal with it. - */ - this.handleError = function(error) { - this.status = 0; - this.statusText = error; - this.responseText = error.stack; - errorFlag = true; - setState(this.DONE); - this.dispatchEvent('error'); - }; - - /** - * Aborts a request. - */ - this.abort = function() { - if (request) { - request.abort(); - request = null; - } + /** + * Adds an event listener. Preferred method of binding to events. + */ + this.addEventListener = function(event, callback) { + if (!(event in listeners)) { + listeners[event] = []; + } + // Currently allows duplicate callbacks. Should it? + listeners[event].push(callback); + }; - headers = defaultHeaders; - this.status = 0; - this.responseText = ""; - this.responseXML = ""; + /** + * Remove an event callback that has already been bound. + * Only works on the matching funciton, cannot be a copy. + */ + this.removeEventListener = function(event, callback) { + if (event in listeners) { + // Filter will return a new array with the callback removed + listeners[event] = listeners[event].filter(function(ev) { + return ev !== callback; + }); + } + }; - errorFlag = true; + /** + * Dispatch any events, including both "on" methods and events attached using addEventListener. + */ + this.dispatchEvent = function(event) { + if (typeof self["on" + event] === "function") { + self["on" + event](); + } + if (event in listeners) { + for (var i = 0, len = listeners[event].length; i < len; i++) { + listeners[event][i].call(self); + } + } + }; - if (this.readyState !== this.UNSENT - && (this.readyState !== this.OPENED || sendFlag) - && this.readyState !== this.DONE) { - sendFlag = false; - setState(this.DONE); - } - this.readyState = this.UNSENT; - this.dispatchEvent('abort'); - }; - - /** - * Adds an event listener. Preferred method of binding to events. - */ - this.addEventListener = function(event, callback) { - if (!(event in listeners)) { - listeners[event] = []; - } - // Currently allows duplicate callbacks. Should it? - listeners[event].push(callback); - }; - - /** - * Remove an event callback that has already been bound. - * Only works on the matching funciton, cannot be a copy. - */ - this.removeEventListener = function(event, callback) { - if (event in listeners) { - // Filter will return a new array with the callback removed - listeners[event] = listeners[event].filter(function(ev) { - return ev !== callback; - }); - } - }; - - /** - * Dispatch any events, including both "on" methods and events attached using addEventListener. - */ - this.dispatchEvent = function(event) { - if (typeof self["on" + event] === "function") { - self["on" + event](); - } - if (event in listeners) { - for (var i = 0, len = listeners[event].length; i < len; i++) { - listeners[event][i].call(self); - } - } - }; - - /** - * Changes readyState and calls onreadystatechange. - * - * @param int state New state - */ - var setState = function(state) { - if (state == self.LOADING || self.readyState !== state) { - self.readyState = state; - - if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) { - self.dispatchEvent("readystatechange"); - } - - if (self.readyState === self.DONE && !errorFlag) { - self.dispatchEvent("load"); - // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) - self.dispatchEvent("loadend"); - } - } - }; + /** + * Changes readyState and calls onreadystatechange. + * + * @param int state New state + */ + var setState = function(state) { + if (state == self.LOADING || self.readyState !== state) { + self.readyState = state; + + if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) { + self.dispatchEvent("readystatechange"); + } + + if (self.readyState === self.DONE && !errorFlag) { + self.dispatchEvent("load"); + // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) + self.dispatchEvent("loadend"); + + if (isCachingRequests && !self.currentSession.hash) { + self.currentSession.responseText = self.responseText; + self.currentSession.responseXML = self.responseXML; + self.currentSession.status = self.status; + self.currentSession.statusText = self.statusText; + + self.currentSession.hash = generateHashForSession(self.currentSession); + if (!requestCache.find(data => data.hash === self.currentSession.hash)) { + requestCache.push(self.currentSession); + } + } + } + } + }; }; diff --git a/tests/test-constants.js b/tests/test-constants.js index 372e46c..183938e 100644 --- a/tests/test-constants.js +++ b/tests/test-constants.js @@ -1,4 +1,4 @@ -var sys = require("util") +var , assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr = new XMLHttpRequest(); @@ -10,4 +10,4 @@ assert.equal(2, xhr.HEADERS_RECEIVED); assert.equal(3, xhr.LOADING); assert.equal(4, xhr.DONE); -sys.puts("done"); +console.log("test-constants done"); diff --git a/tests/test-events.js b/tests/test-events.js index c72f001..1ea8eb8 100644 --- a/tests/test-events.js +++ b/tests/test-events.js @@ -1,9 +1,9 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , http = require("http") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr; + // Test server var server = http.createServer(function (req, res) { var body = (req.method != "HEAD" ? "Hello World" : ""); @@ -20,10 +20,12 @@ var server = http.createServer(function (req, res) { assert.equal(onreadystatechange, true); assert.equal(readystatechange, true); assert.equal(removed, true); - sys.puts("done"); + console.log("test-events done"); this.close(); }).listen(8000); + + xhr = new XMLHttpRequest(); // Track event calls diff --git a/tests/test-exceptions.js b/tests/test-exceptions.js index f1edd71..8ca06f3 100644 --- a/tests/test-exceptions.js +++ b/tests/test-exceptions.js @@ -59,4 +59,4 @@ for (var i in forbiddenRequestHeaders) { // Try valid header xhr.setRequestHeader("X-Foobar", "Test"); -console.log("Done"); +console.log("test-exceptions Done"); diff --git a/tests/test-headers.js b/tests/test-headers.js index 23a419e..30cbb74 100644 --- a/tests/test-headers.js +++ b/tests/test-headers.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr = new XMLHttpRequest() , http = require("http"); @@ -47,7 +46,7 @@ xhr.onreadystatechange = function() { assert.equal("", this.getAllResponseHeaders()); assert.equal(null, this.getResponseHeader("Connection")); - sys.puts("done"); + console.log("test-headers doneeeee"); } }; diff --git a/tests/test-redirect-301.js b/tests/test-redirect-301.js index 4d844c1..26c5ab0 100644 --- a/tests/test-redirect-301.js +++ b/tests/test-redirect-301.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr = new XMLHttpRequest() , http = require("http"); @@ -30,7 +29,7 @@ xhr.onreadystatechange = function() { assert.equal(xhr.status, 200); assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); - sys.puts("done"); + console.log("test-redirect-301 done"); } }; diff --git a/tests/test-redirect-302.js b/tests/test-redirect-302.js index d884f78..2bcf2df 100644 --- a/tests/test-redirect-302.js +++ b/tests/test-redirect-302.js @@ -29,7 +29,7 @@ xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); - sys.puts("done"); + console.log("test-redirect-302 done"); } }; diff --git a/tests/test-redirect-303.js b/tests/test-redirect-303.js index 60d9343..a797b5e 100644 --- a/tests/test-redirect-303.js +++ b/tests/test-redirect-303.js @@ -29,7 +29,7 @@ xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); - sys.puts("done"); + console.log("test-redirect-303 done"); } }; diff --git a/tests/test-redirect-307.js b/tests/test-redirect-307.js index 3abc906..859a152 100644 --- a/tests/test-redirect-307.js +++ b/tests/test-redirect-307.js @@ -31,7 +31,7 @@ xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); - sys.puts("done"); + console.log("test-redirect-307 done"); } }; diff --git a/tests/test-request-methods.js b/tests/test-request-methods.js index fa1b1be..9cd9cb4 100644 --- a/tests/test-request-methods.js +++ b/tests/test-request-methods.js @@ -24,7 +24,7 @@ var server = http.createServer(function (req, res) { if (curMethod == methods.length - 1) { this.close(); - sys.puts("done"); + console.log("test-request-methods done"); } }).listen(8000); @@ -47,7 +47,7 @@ function start(method) { curMethod++; if (curMethod < methods.length) { - sys.puts("Testing " + methods[curMethod]); + console.log("Testing " + methods[curMethod]); start(methods[curMethod]); } } @@ -58,5 +58,5 @@ function start(method) { xhr.send(); } -sys.puts("Testing " + methods[curMethod]); +console.log("Testing " + methods[curMethod]); start(methods[curMethod]); diff --git a/tests/test-request-protocols.js b/tests/test-request-protocols.js index 543917d..8f1c0f4 100644 --- a/tests/test-request-protocols.js +++ b/tests/test-request-protocols.js @@ -24,7 +24,7 @@ var runSync = function() { xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal("Hello World", this.responseText); - sys.puts("done"); + console.log("test-request-protocols done"); } }; xhr.open("GET", url, false); diff --git a/tests/test-streaming.js b/tests/test-streaming.js index ea0fb82..96c8247 100644 --- a/tests/test-streaming.js +++ b/tests/test-streaming.js @@ -12,7 +12,7 @@ function completeResponse(res,server,body) { assert.equal(readystatechange, true); assert.equal(removed, true); assert.equal(loadCount, body.length); - sys.puts("done"); + console.log("test-streaming done"); server.close(); } function push(res,piece) { @@ -26,7 +26,7 @@ var server = http.createServer(function (req, res) { "Content-Type": "text/plain", "Content-Length": Buffer.byteLength(body.join("")) }); - + var nextPiece = 0; var self = this; var interval = setInterval(function() {