Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/node_modules/*
244 changes: 141 additions & 103 deletions lib/XMLHttpRequest.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/*jslint node:true, vars:true, todo:true, stupid:true, regexp:true, sloppy: true*/
/**
* Wrapper for built-in http.js to emulate the browser XMLHttpRequest object.
*
Expand All @@ -11,6 +12,9 @@
* @license MIT
*/

require('array.prototype.find');
require('string.prototype.includes');

var Url = require("url")
, spawn = require("child_process").spawn
, fs = require('fs');
Expand All @@ -37,7 +41,7 @@ exports.XMLHttpRequest = function() {
// Set some default headers
var defaultHeaders = {
"User-Agent": "node-XMLHttpRequest",
"Accept": "*/*",
"Accept": "*/*"
};

var headers = defaultHeaders;
Expand Down Expand Up @@ -113,6 +117,27 @@ exports.XMLHttpRequest = function() {
* Private methods
*/

/**
* 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");
}
}
};

/**
* Check if the specified header is allowed.
*
Expand Down Expand Up @@ -183,7 +208,7 @@ exports.XMLHttpRequest = function() {
* @param string value Header value
*/
this.setRequestHeader = function(header, value) {
if (this.readyState != this.OPENED) {
if (this.readyState !== this.OPENED) {
throw "INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN";
}
if (!isAllowedHttpHeader(header)) {
Expand Down Expand Up @@ -226,11 +251,13 @@ exports.XMLHttpRequest = function() {
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";
var i;
for (i in response.headers) {
if (response.headers.hasOwnProperty(i)) {
// 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);
Expand All @@ -257,7 +284,7 @@ exports.XMLHttpRequest = function() {
* @param string data Optional data to send as request body.
*/
this.send = function(data) {
if (this.readyState != this.OPENED) {
if (this.readyState !== this.OPENED) {
throw "INVALID_STATE_ERR: connection must be opened before send() is called";
}

Expand All @@ -268,11 +295,23 @@ exports.XMLHttpRequest = function() {
var ssl = false, local = false;
var url = Url.parse(settings.url);
var host;

function getStack () {
var orig = Error.prepareStackTrace;
Error.prepareStackTrace = function(_, stack){ return stack; };
var err = new Error();
Error.captureStackTrace(err, arguments.callee);
var stack = err.stack;
Error.prepareStackTrace = orig;
return stack;
}

// Determine the server
switch (url.protocol) {
case 'https:':
ssl = true;
// SSL & non-SSL both need host, no break here.
/* falls through */
case 'http:':
host = url.hostname;
break;
Expand All @@ -282,8 +321,25 @@ exports.XMLHttpRequest = function() {
break;

case undefined:
case null:
case '':
host = "localhost";
var stack = getStack();
var path = require('path');
var basePath = path.dirname(stack.reverse().find(function (item) {
var filename = item.getFileName();
var idx = filename.search(/[\/\\]node_modules[\/\\]/);
if (idx === -1) { // Should be a user file, as a node executable like nodeunit ought to have node_modules in the path
return true;
}
// Should be a user file because its last "node_modules" contains this XMLHttpRequest file (i.e., XMLHttpRequest is a dependency of some kind)
if (__dirname.includes(filename.slice(0, idx))) {
return true;
}
return false;
}).getFileName());
var pathName = path.resolve(basePath, settings.url);
url = {pathname: pathName};
local = true;
break;

default:
Expand Down Expand Up @@ -323,21 +379,21 @@ exports.XMLHttpRequest = function() {
// 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 : '');
var uri = url.pathname + (url.search || '');

// Set the Host header or the server may reject the request
headers["Host"] = host;
headers.Host = host;
if (!((ssl && port === 443) || port === 80)) {
headers["Host"] += ':' + url.port;
headers.Host += ':' + url.port;
}

// Set Basic Auth if necessary
if (settings.user) {
if (typeof settings.password == "undefined") {
if (settings.password === undefined) {
settings.password = "";
}
var authBuf = new Buffer(settings.user + ":" + settings.password);
headers["Authorization"] = "Basic " + authBuf.toString("base64");
headers.Authorization = "Basic " + authBuf.toString("base64");
}

// Set content length header
Expand All @@ -364,82 +420,84 @@ exports.XMLHttpRequest = function() {
agent: false
};

var doRequest;

// Reset error flag
errorFlag = false;

// Handle async requests
if (settings.async) {
// Use the proper protocol
var doRequest = ssl ? https.request : http.request;
// Error handler for the request
function errorHandler(error) {
self.handleError(error);
}

// Request is being sent, set send flag
sendFlag = true;
// Handler for the response
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;
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
};

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

// As per spec, this is called here for historical reasons.
self.dispatchEvent("readystatechange");
response.setEncoding("utf8");

// Handler for the response
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,
headers: headers
};

// 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;
}
setState(self.HEADERS_RECEIVED);
self.status = response.statusCode;

response.setEncoding("utf8");
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);
}
});

setState(self.HEADERS_RECEIVED);
self.status = response.statusCode;
response.on('end', function() {
if (sendFlag) {
// Discard the 'end' event if the connection has been aborted
setState(self.DONE);
sendFlag = false;
}
});

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('error', function(error) {
self.handleError(error);
});
}

response.on('end', function() {
if (sendFlag) {
// Discard the 'end' event if the connection has been aborted
setState(self.DONE);
sendFlag = false;
}
});
// Handle async requests
if (settings.async) {
// Use the proper protocol
doRequest = ssl ? https.request : http.request;

response.on('error', function(error) {
self.handleError(error);
});
}
// Request is being sent, set send flag
sendFlag = true;

// Error handler for the request
function errorHandler(error) {
self.handleError(error);
}
// As per spec, this is called here for historical reasons.
self.dispatchEvent("readystatechange");

// Create the request
request = doRequest(options, responseHandler).on('error', errorHandler);
Expand Down Expand Up @@ -483,7 +541,6 @@ exports.XMLHttpRequest = function() {
+ "req.end();";
// Start the other Node Process, executing this string
var syncProc = spawn(process.argv[0], ["-e", execString]);
var statusText;
while(fs.existsSync(syncFile)) {
// Wait while the sync file is empty
}
Expand Down Expand Up @@ -544,7 +601,7 @@ exports.XMLHttpRequest = function() {
* Adds an event listener. Preferred method of binding to events.
*/
this.addEventListener = function(event, callback) {
if (!(event in listeners)) {
if (!(listeners.hasOwnProperty(event))) {
listeners[event] = [];
}
// Currently allows duplicate callbacks. Should it?
Expand All @@ -556,7 +613,7 @@ exports.XMLHttpRequest = function() {
* Only works on the matching funciton, cannot be a copy.
*/
this.removeEventListener = function(event, callback) {
if (event in listeners) {
if (listeners.hasOwnProperty(event)) {
// Filter will return a new array with the callback removed
listeners[event] = listeners[event].filter(function(ev) {
return ev !== callback;
Expand All @@ -571,31 +628,12 @@ exports.XMLHttpRequest = function() {
if (typeof self["on" + event] === "function") {
self["on" + event]();
}
if (event in listeners) {
for (var i = 0, len = listeners[event].length; i < len; i++) {
var i, len;
if (listeners.hasOwnProperty(event)) {
for (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");
}
}
};
};
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
, "engines": {
"node": ">=0.4.0"
}
, "dependencies": {
"array.prototype.find": "1.x"
, "string.prototype.includes": "1.x"
}
, "directories": {
"lib": "./lib"
, "example": "./example"
Expand Down