diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..555b1b7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: Test +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] +jobs: + integration-tests: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [12.x, 20.x, 22.x, 23.x] + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - run: npm test \ No newline at end of file diff --git a/README.md b/README.md index b989434..c6c7912 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,40 @@ # node-XMLHttpRequest # -node-XMLHttpRequest is a wrapper for the built-in http client to emulate the -browser XMLHttpRequest object. +Fork of [node-XMLHttpRequest](https://github.com/driverdan/node-XMLHttpRequest) by [driverdan](http://driverdan.com). Forked and published to npm because a [pull request](https://github.com/rase-/node-XMLHttpRequest/commit/a6b6f296e0a8278165c2d0270d9840b54d5eeadd) is not being created and merged. Changes made by [rase-](https://github.com/rase-/node-XMLHttpRequest/tree/add/ssl-support) are needed for [engine.io-client](https://github.com/Automattic/engine.io-client). -This can be used with JS designed for browsers to improve reuse of code and -allow the use of existing libraries. - -Note: This library currently conforms to [XMLHttpRequest 1](http://www.w3.org/TR/XMLHttpRequest/). Version 2.0 will target [XMLHttpRequest Level 2](http://www.w3.org/TR/XMLHttpRequest2/). - -## Usage ## +## Usage ## Here's how to include the module in your project and use as the browser-based XHR object. - var XMLHttpRequest = require("xmlhttprequest").XMLHttpRequest; + var XMLHttpRequest = require("xmlhttprequest-ssl").XMLHttpRequest; var xhr = new XMLHttpRequest(); -Note: use the lowercase string "xmlhttprequest" in your require(). On +Note: use the lowercase string "xmlhttprequest-ssl" in your require(). On case-sensitive systems (eg Linux) using uppercase letters won't work. +### Non-standard features ### + +Non-standard options for this module are passed through the `XMLHttpRequest` constructor. The following options control `https:` SSL requests: `ca`, `cert`, `ciphers`, `key`, `passphrase`, `pfx`, and `rejectUnauthorized`. You can find their functionality in the [Node.js docs](https://nodejs.org/api/https.html#httpsrequestoptions-callback). + +Additionally, the `agent` option allows you to specify a [Node.js Agent](https://nodejs.org/api/https.html#class-httpsagent) instance, allowing connection reuse. + +To prevent a process from not exiting naturally because a request socket from this module is still open, you can set `autoUnref` to a truthy value. + +This module allows control over the maximum number of redirects that are followed. You can set the `maxRedirects` option to do this. The default number is 20. + +Using the `allowFileSystemResources` option allows you to control access to the local filesystem through the `file:` protocol. + +The `origin` option allows you to set a base URL for the request. The resulting request URL will be constructed as follows `new URL(url, origin)`. + +# Original README # + ## Versions ## +Version 2.0.0 introduces a potentially breaking change concerning local file system requests. +If these requests fail this library now returns the `errno` (or -1) as the response status code instead of +returning status code 0. + Prior to 1.4.0 version numbers were arbitrary. From 1.4.0 on they conform to the standard major.minor.bugfix. 1.x shouldn't necessarily be considered stable just because it's above 0.x. diff --git a/example/demo.js b/example/demo.js index 4f333de..1872ab5 100644 --- a/example/demo.js +++ b/example/demo.js @@ -4,11 +4,11 @@ var XMLHttpRequest = require("xmlhttprequest").XMLHttpRequest; var xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { - sys.puts("State: " + this.readyState); - + console.log("State: " + this.readyState); + if (this.readyState == 4) { - sys.puts("Complete.\nBody length: " + this.responseText.length); - sys.puts("Body:\n" + this.responseText); + console.log("Complete.\nBody length: " + this.responseText.length); + console.log("Body:\n" + this.responseText); } }; diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 52e36b3..8bbc904 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -12,7 +12,6 @@ */ var fs = require('fs'); -var Url = require('url'); var spawn = require('child_process').spawn; /** @@ -35,6 +34,36 @@ XMLHttpRequest.XMLHttpRequest = XMLHttpRequest; */ function XMLHttpRequest(opts) { + "use strict"; + + // defines a list of default options to prevent parameters pollution + var default_options = { + pfx: undefined, + key: undefined, + passphrase: undefined, + cert: undefined, + ca: undefined, + ciphers: undefined, + rejectUnauthorized: true, + autoUnref: false, + agent: undefined, + allowFileSystemResources: true, + maxRedirects: 20, // Chrome standard + origin: undefined + }; + + opts = Object.assign(default_options, opts); + + var sslOptions = { + pfx: opts.pfx, + key: opts.key, + passphrase: opts.passphrase, + cert: opts.cert, + ca: opts.ca, + ciphers: opts.ciphers, + rejectUnauthorized: opts.rejectUnauthorized !== false + }; + /** * Private variables */ @@ -42,6 +71,12 @@ function XMLHttpRequest(opts) { var http = require('http'); var https = require('https'); + var maxRedirects = opts.maxRedirects; + if (typeof maxRedirects !== 'number' || Number.isNaN(maxRedirects)) maxRedirects = 20; + else maxRedirects = Math.max(maxRedirects, 0); + + var redirectCount = 0; + // Holds http.js objects var request; var response; @@ -59,7 +94,7 @@ function XMLHttpRequest(opts) { "Accept": "*/*" }; - var headers = defaultHeaders; + var headers = Object.assign({}, defaultHeaders); // These headers are not user setable. // The following are allowed but banned in the spec: @@ -98,6 +133,7 @@ function XMLHttpRequest(opts) { var sendFlag = false; // Error flag, used when errors occur or abort is called var errorFlag = false; + var abortedFlag = false; // Event listeners var listeners = {}; @@ -125,9 +161,20 @@ function XMLHttpRequest(opts) { // Result & response this.responseText = ""; this.responseXML = ""; + this.responseURL = ""; + this.response = Buffer.alloc(0); this.status = null; this.statusText = null; + // xhr.responseType is supported: + // When responseType is 'text' or '', self.responseText will be utf8 decoded text. + // When responseType is 'json', self.responseText initially will be utf8 decoded text, + // which is then JSON parsed into self.response. + // When responseType is 'arraybuffer', self.response is an ArrayBuffer. + // When responseType is 'blob', self.response is a Blob. + // cf. section 3.6, subsections 8,9,10,11 of https://xhr.spec.whatwg.org/#the-response-attribute + this.responseType = ""; /* 'arraybuffer' or 'text' or '' or 'json' or 'blob' */ + /** * Private methods */ @@ -152,6 +199,71 @@ function XMLHttpRequest(opts) { return (method && forbiddenRequestMethods.indexOf(method) === -1); }; + /** + * When xhr.responseType === 'arraybuffer', xhr.response must have type ArrayBuffer according + * to section 3.6.9 of https://xhr.spec.whatwg.org/#the-response-attribute . + * However, bufTotal = Buffer.concat(...) often has byteOffset > 0, so bufTotal.buffer is larger + * than the useable region in bufTotal. This means that a new copy of bufTotal would need to be + * created to get the correct ArrayBuffer. Instead, do the concat by hand to create the right + * sized ArrayBuffer in the first place. + * + * The return type is Uint8Array, + * because often Buffer will have Buffer.length < Buffer.buffer.byteLength. + * + * @param {Array} bufferArray + * @returns {Uint8Array} + */ + var concat = function(bufferArray) { + let length = 0, offset = 0; + for (let k = 0; k < bufferArray.length; k++) + length += bufferArray[k].length; + const result = new Uint8Array(length); + for (let k = 0; k < bufferArray.length; k++) + { + result.set(bufferArray[k], offset); + offset += bufferArray[k].length; + } + return result; + }; + + /** + * When xhr.responseType === 'arraybuffer', xhr.response must have type ArrayBuffer according + * to section 3.6.9 of https://xhr.spec.whatwg.org/#the-response-attribute . + * However, buf = Buffer.from(str) often has byteOffset > 0, so buf.buffer is larger than the + * usable region in buf. This means that a new copy of buf would need to be created to get the + * correct arrayBuffer. Instead, do it by hand to create the right sized ArrayBuffer in the + * first place. + * + * @param {string} str + * @returns {Buffer} + */ + var stringToBuffer = function(str) { + const ab = new ArrayBuffer(str.length) + const buf = Buffer.from(ab); + for (let k = 0; k < str.length; k++) + buf[k] = Number(str.charCodeAt(k)); + return buf; + } + + /** + * Given a Buffer buf, check whether buf.buffer.byteLength > buf.length and if so, + * create a new ArrayBuffer whose byteLength is buf.length, containing the bytes. + * of buf. This function shouldn't usually be needed, unless there's a future + * behavior change where buf.buffer.byteLength > buf.length unexpectedly. + * + * @param {Buffer} buf + * @returns {ArrayBuffer} + */ + var checkAndShrinkBuffer = function(buf) { + if (buf.length === buf.buffer.byteLength) + return buf.buffer; + const ab = new ArrayBuffer(buf.length); + const result = Buffer.from(ab); + for (let k = 0; k < buf.length; k++) + result[k] = buf[k]; + return ab; + } + /** * Public methods */ @@ -168,20 +280,29 @@ function XMLHttpRequest(opts) { this.open = function(method, url, async, user, password) { this.abort(); errorFlag = false; + abortedFlag = false; // Check for valid request method if (!isAllowedHttpMethod(method)) { - throw "SecurityError: Request method not allowed"; + throw new Error("SecurityError: Request method not allowed"); } settings = { - "method": method, - "url": url.toString(), + "method": method.toUpperCase(), + "url": url, "async": (typeof async !== "boolean" ? true : async), "user": user || null, "password": password || null }; + // parse origin + try { + settings.origin = new URL(opts.origin); + } + catch (e) { + settings.origin = null; + } + setState(this.OPENED); }; @@ -200,19 +321,21 @@ function XMLHttpRequest(opts) { * * @param string header Header name * @param string value Header value + * @return boolean Header added */ this.setRequestHeader = function(header, value) { if (this.readyState != this.OPENED) { - throw "INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN"; + 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; + return false; } if (sendFlag) { - throw "INVALID_STATE_ERR: send flag is true"; + throw new Error("INVALID_STATE_ERR: send flag is true"); } headers[header] = value; + return true; }; /** @@ -222,10 +345,13 @@ function XMLHttpRequest(opts) { * @return string Text of the header or null if it doesn't exist. */ this.getResponseHeader = function(header) { + // in case of local request, headers are not present if (typeof header === "string" && this.readyState > this.OPENED && response.headers[header.toLowerCase()] && !errorFlag + && response + && response.headers ) { return response.headers[header.toLowerCase()]; } @@ -239,7 +365,8 @@ function XMLHttpRequest(opts) { * @return string A string with all response headers separated by CR+LF */ this.getAllResponseHeaders = function() { - if (this.readyState < this.HEADERS_RECEIVED || errorFlag) { + // in case of local request, headers are not present + if (this.readyState < this.HEADERS_RECEIVED || errorFlag || !response || !response.headers) { return ""; } var result = ""; @@ -250,7 +377,7 @@ function XMLHttpRequest(opts) { result += i + ": " + response.headers[i] + "\r\n"; } } - return result.substr(0, result.length - 2); + return result.slice(0, -2); }; /** @@ -268,6 +395,44 @@ function XMLHttpRequest(opts) { return ""; }; + /** + * Convert from Data URI to Buffer + * @param {URL} url URI to parse + * @returns {Buffer} buffer + */ + + var bufferFromDataUri = function (url) { + // Triming from original url object for more consistency + var data = url.href.slice(5); + + // separator between header and actual data + var parts = data.split(",", 2); + + if (parts.length < 2) throw "Invalid URL"; + + // check if header part has base64 (from 2nd header onwards) + var base64 = parts[0].split(";").some(function (dataHeader, index) { + return index > 0 && dataHeader.toLowerCase() === "base64"; + }); + + var responseData, inputData = decodeURIComponent(parts[1]); + + if (base64) { + // remove any ASCII whitespaces + inputData = inputData.replace(/(\s|\t|\r|\n|\v|\f)+/g, ""); + // check padding amount + let padding = inputData.match(/=*$/)[0]; + if (padding.length + (inputData.length - padding.length) % 4 > 4) throw "invalid padding"; + inputData = inputData.slice(0, inputData.length - padding.length); + responseData = Buffer.from(inputData, "base64"); + if (responseData.toString("base64").replace(/=+$/, "") !== inputData) throw "malformed base64 encoding"; + return responseData; + } + else { + return Buffer.from(inputData); + } + } + /** * Sends the request to the server. * @@ -275,27 +440,44 @@ function XMLHttpRequest(opts) { */ this.send = function(data) { if (this.readyState != this.OPENED) { - throw "INVALID_STATE_ERR: connection must be opened before send() is called"; + throw new Error("INVALID_STATE_ERR: connection must be opened before send() is called"); } if (sendFlag) { - throw "INVALID_STATE_ERR: send has already been called"; + throw new Error("INVALID_STATE_ERR: send has already been called"); } - var ssl = false, local = false; - var url = Url.parse(settings.url); + var isSsl = false, isLocal = false, isDataUri = false; + var url; + try { + if (settings.origin) { + url = new URL(settings.url, settings.origin); + } + else { + url = new URL(settings.url); + } + settings.url = url.href; + } + catch (e) { + // URL parsing throws TypeError, here we only want to take its message + self.handleError(new Error(e.message)); + return; + } var host; // Determine the server switch (url.protocol) { case 'https:': - ssl = true; + isSsl = true; // SSL & non-SSL both need host, no break here. case 'http:': host = url.hostname; break; + case 'data:': + isDataUri = true; + case 'file:': - local = true; + isLocal = true; break; case undefined: @@ -304,32 +486,55 @@ function XMLHttpRequest(opts) { break; default: - throw "Protocol not supported."; + throw new Error("Protocol not supported."); } // Load files off the local filesystem (file://) - if (local) { + // or data from Data URI (data:) + if (isLocal) { + if (isDataUri) try { + self.status = 200; + self.responseURL = settings.url; + self.createFileOrSyncResponse(bufferFromDataUri(url)); + setState(self.DONE); + return; + } + catch (e) { + self.handleError(new Error("Invalid data URI")); + return; + } + + if (!opts.allowFileSystemResources) { + self.handleError(new Error("Not allowed to load local resource: " + url.href)); + return; + } + if (settings.method !== "GET") { - throw "XMLHttpRequest: Only GET method is supported"; + throw new Error("XMLHttpRequest: Only GET method is supported"); } if (settings.async) { - fs.readFile(url.pathname, 'utf8', function(error, data) { + fs.readFile(unescape(url.pathname), function(error, data) { if (error) { - self.handleError(error); + self.handleError(error, error.errno || -1); } else { self.status = 200; - self.responseText = data; + self.responseURL = settings.url; + // Use self.responseType to create the correct self.responseType, self.response. + self.createFileOrSyncResponse(data); setState(self.DONE); } }); } else { try { - this.responseText = fs.readFileSync(url.pathname, 'utf8'); this.status = 200; + const syncData = fs.readFileSync(unescape(url.pathname)); + // Use self.responseType to create the correct self.responseType, self.response. + this.responseURL = settings.url; + this.createFileOrSyncResponse(syncData); setState(self.DONE); } catch(e) { - this.handleError(e); + this.handleError(e, e.errno || -1); } } @@ -338,22 +543,22 @@ function XMLHttpRequest(opts) { // 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); + var port = url.port || (isSsl ? 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; - if (!((ssl && port === 443) || port === 80)) { + if (!((isSsl && port === 443) || port === 80)) { headers["Host"] += ':' + url.port; } // Set Basic Auth if necessary if (settings.user) { - if (typeof settings.password == "undefined") { + if (typeof settings.password === "undefined") { settings.password = ""; } - var authBuf = new Buffer(settings.user + ":" + settings.password); + var authBuf = Buffer.from(settings.user + ":" + settings.password); headers["Authorization"] = "Basic " + authBuf.toString("base64"); } @@ -363,7 +568,8 @@ function XMLHttpRequest(opts) { } else if (data) { headers["Content-Length"] = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data); - if (!headers["Content-Type"]) { + var headersKeys = Object.keys(headers); + if (!headersKeys.some(function (h) { return h.toLowerCase() === 'content-type' })) { headers["Content-Type"] = "text/plain;charset=UTF-8"; } } else if (settings.method === "POST") { @@ -372,36 +578,21 @@ function XMLHttpRequest(opts) { headers["Content-Length"] = 0; } - var agent = false; - if (opts && opts.agent) { - agent = opts.agent; - } var options = { host: host, port: port, path: uri, method: settings.method, headers: headers, - agent: agent + agent: opts.agent || false }; - if (ssl) { - options.pfx = opts.pfx; - options.key = opts.key; - options.passphrase = opts.passphrase; - options.cert = opts.cert; - options.ca = opts.ca; - options.ciphers = opts.ciphers; - options.rejectUnauthorized = opts.rejectUnauthorized; - } - // Reset error flag errorFlag = false; - // Handle async requests if (settings.async) { // Use the proper protocol - var doRequest = ssl ? https.request : http.request; + var doRequest = isSsl ? https.request : http.request; // Request is being sent, set send flag sendFlag = true; @@ -410,87 +601,151 @@ function XMLHttpRequest(opts) { self.dispatchEvent("readystatechange"); // 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; + var responseHandler = function(resp) { // Check for redirect - // @TODO Prevent looped redirects - if (response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307) { + if ( + resp.statusCode === 301 || + resp.statusCode === 302 || + resp.statusCode === 303 || + resp.statusCode === 307 || + resp.statusCode === 308 + ) { + ++redirectCount; + // end the response + resp.destroy(); + if (redirectCount > maxRedirects) { + self.handleError(new Error("Too many redirects")); + return; + } // 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 + var url; + try { + url = new URL(resp.headers.location, settings.url); + // reject redirects to any protocols other than http and https + if (url.protocol !== "https:" && url.protocol !== "http:") throw "bad protocol"; + settings.url = url.href; + } + catch (e) { + self.handleError(new Error("Unsafe redirect")); + return; + } + // change request options again to match with new redirect protocol + isSsl = url.protocol === "https:"; + doRequest = isSsl ? https.request : http.request; + + // Set host and port var in case it's used later host = url.hostname; + port = url.port || (isSsl ? 443 : 80); + + headers["Host"] = host; + if (!((isSsl && port === 443) || port === 80)) { + headers["Host"] += ':' + url.port; + } + // Options for the new request var newOptions = { hostname: url.hostname, - port: url.port, - path: url.path, - method: response.statusCode === 303 ? 'GET' : settings.method, + port: port, + path: url.pathname + (url.search || ''), + method: resp.statusCode === 303 ? 'GET' : settings.method, headers: headers }; - if (ssl) { - options.pfx = opts.pfx; - options.key = opts.key; - options.passphrase = opts.passphrase; - options.cert = opts.cert; - options.ca = opts.ca; - options.ciphers = opts.ciphers; - options.rejectUnauthorized = opts.rejectUnauthorized; - } - // Issue the new request - request = doRequest(newOptions, responseHandler).on('error', errorHandler); - request.end(); + createRequest(newOptions); // @TODO Check if an XHR event needs to be fired here return; } - response.setEncoding("utf8"); + // Set response var to the response we got back + // This is so it remains accessable outside this scope + response = resp; + // Collect buffers and concatenate once. + const buffers = []; setState(self.HEADERS_RECEIVED); + + // When responseType is 'text' or '', self.responseText will be utf8 decoded text. + // When responseType is 'json', self.responseText initially will be utf8 decoded text, + // which is then JSON parsed into self.response. + // When responseType is 'arraybuffer', self.response is an ArrayBuffer. + // When responseType is 'blob', self.response is a Blob. + // cf. section 3.6, subsections 8,9,10,11 of https://xhr.spec.whatwg.org/#the-response-attribute + const isUtf8 = self.responseType === "" || self.responseType === "text" || self.responseType === "json"; + if (isUtf8 && response.setEncoding) { + response.setEncoding("utf8"); + } + self.status = response.statusCode; response.on('data', function(chunk) { // Make sure there's some data if (chunk) { - self.responseText += chunk; + if (isUtf8) { + // When responseType is 'text', '', 'json', + // then each chunk is already utf8 decoded. + self.responseText += chunk; + } else { + // Otherwise collect the chunk buffers. + buffers.push(chunk); + } } // Don't emit state changes if the connection has been aborted. if (sendFlag) { setState(self.LOADING); } - }); + }.bind(response)); response.on('end', function() { if (sendFlag) { + // The sendFlag needs to be set before setState is called. Otherwise if we are chaining callbacks + // there can be a timing issue (the callback is called and a new call is made before the flag is reset). + sendFlag = false; + // Create the correct response for responseType. + self.createResponse(buffers); + self.statusText = this.statusMessage; + self.responseURL = settings.url; // Discard the 'end' event if the connection has been aborted setState(self.DONE); - sendFlag = false; } - }); + }.bind(response)); response.on('error', function(error) { self.handleError(error); - }); + }.bind(response)); } // Error handler for the request - function errorHandler(error) { + var errorHandler = function(error) { + // In the case of https://nodejs.org/api/http.html#requestreusedsocket triggering an ECONNRESET, + // don't fail the xhr request, attempt again. + if (request.reusedSocket && error.code === 'ECONNRESET') + return doRequest(options, responseHandler).on('error', errorHandler); self.handleError(error); } - // Create the request - request = doRequest(options, responseHandler).on('error', errorHandler); + var createRequest = function (opt) { + opt = Object.assign({}, opt); + if (isSsl) Object.assign(opt, sslOptions); + + request = doRequest(opt, responseHandler).on('error', errorHandler); + + if (opts.autoUnref) { + request.on('socket', function (socket) { + socket.unref(); + }); + } + + // Node 0.4 and later won't accept empty data. Make sure it's needed. + if (data) { + request.write(data); + } - // Node 0.4 and later won't accept empty data. Make sure it's needed. - if (data) { - request.write(data); + request.end(); } - request.end(); + // Create the request + createRequest(options); self.dispatchEvent("loadstart"); } else { // Synchronous @@ -499,32 +754,87 @@ function XMLHttpRequest(opts) { 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 execString = "'use strict';" + + "var http = require('http'), https = require('https'), fs = require('fs');" + + "function concat(bufferArray) {" + + " let length = 0, offset = 0;" + + " for (let k = 0; k < bufferArray.length; k++)" + + " length += bufferArray[k].length;" + + " const result = Buffer.alloc(length);" + + " for (let k = 0; k < bufferArray.length; k++) {" + + " for (let i = 0; i < bufferArray[k].length; i++) {" + + " result[offset+i] = bufferArray[k][i]" + + " }" + + " offset += bufferArray[k].length;" + + " }" + + " return result;" + + "};" + + "var doRequest = http" + (isSsl ? "s" : "") + ".request;" + + "var isSsl = " + !!isSsl + ";" + "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 + "', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + "response.on('error', function(error) {" - + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + "}).on('error', function(error) {" - + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + (data ? "req.write('" + data.replace(/'/g, "\\'") + "');":"") - + "req.end();"; + + "var sslOptions = " + JSON.stringify(sslOptions) + ";" + + "var responseData = Buffer.alloc(0);" + + "var buffers = [];" + + "var url = new URL(" + JSON.stringify(settings.url) + ");" + + "var maxRedirects = " + maxRedirects + ", redirects_count = 0;" + + "var makeRequest = function () {" + + " var opt = Object.assign({}, options);" + + " if (isSsl) Object.assign(opt, sslOptions);" + + " var req = doRequest(opt, function(response) {" + + " if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307 || response.statusCode === 308) {" + + " response.destroy();" + + " ++redirects_count;" + + " if (redirects_count > maxRedirects) {" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR-REDIRECT: Too many redirects', 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " return;" + + " }" + + " try {" + + " url = new URL(response.headers.location, url);" + + " if (url.protocol !== 'https:' && url.protocol !== 'http:') throw 'bad protocol';" + + " }" + + " catch (e) {" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR-REDIRECT: Unsafe redirect', 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " return;" + + " };" + + " isSsl = url.protocol === 'https:';" + + " doRequest = isSsl ? https.request : http.request;" + + " var port = url.port;" + + " options = {" + + " hostname: url.hostname," + + " port: port," + + " path: url.pathname + (url.search || '')," + + " method: response.statusCode === 303 ? 'GET' : options.method," + + " headers: options.headers" + + " };" + + " options.headers['Host'] = url.host;" + + " if (!((isSsl && port === 443) || port === 80)) options.headers['Host'] += ':' + port;" + + " makeRequest();" + + " return;" + + " }" + + " response.on('data', function(chunk) {" + + " buffers.push(chunk);" + + " });" + + " response.on('end', function() {" + + " responseData = concat(buffers);" + + " fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: {url: url.href, statusCode: response.statusCode, statusText: response.statusMessage, headers: response.headers, data: responseData.toString('utf8')}}), 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " });" + + " response.on('error', function(error) {" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " });" + + " }).on('error', function(error) {" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " });" + + " " + (data ? "req.write('" + JSON.stringify(data).slice(1,-1).replace(/'/g, "\\'") + "');":"") + + " req.end();" + + "};" + + "makeRequest();" // 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 } @@ -533,14 +843,29 @@ function XMLHttpRequest(opts) { syncProc.stdin.end(); // Remove the temporary file fs.unlinkSync(contentFile); - if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) { + if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR(-REDIRECT){0,1}:/)) { // If the file returned an error, handle it - var errorObj = self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, ""); - self.handleError(errorObj); + if (self.responseText.startsWith('NODE-XMLHTTPREQUEST-ERROR-REDIRECT')) { + self.handleError(new Error(self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR-REDIRECT: /, ""))); + } + else { + var errorObj = JSON.parse(self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, "")); + self.handleError(errorObj, 503); + } } else { // If the file returned okay, parse its data and move to the DONE state - self.status = self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:([0-9]*),.*/, "$1"); - self.responseText = self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, "$1"); + const resp = JSON.parse(self.responseText); + self.status = resp.data.statusCode; + self.statusText = resp.data.statusText; + self.responseURL = resp.data.url; + self.response = stringToBuffer(resp.data.data); + // Use self.responseType to create the correct self.responseType, self.response, self.responseXML. + self.createFileOrSyncResponse(self.response); + // Set up response correctly. + response = { + statusCode: self.status, + headers: resp.data.headers + }; setState(self.DONE); } } @@ -548,13 +873,18 @@ function XMLHttpRequest(opts) { /** * Called when an error is encountered to deal with it. + * @param status {number} HTTP status code to use rather than the default (0) for XHR errors. */ - this.handleError = function(error) { - this.status = 503; - this.statusText = error; - this.responseText = error.stack; + this.handleError = function(error, status) { + this.status = status || 0; + this.statusText = error.message || ""; + this.responseText = ""; + this.responseXML = ""; + this.responseURL = ""; + this.response = Buffer.alloc(0); errorFlag = true; setState(this.DONE); + if (!settings.async) throw error; }; /** @@ -566,12 +896,12 @@ function XMLHttpRequest(opts) { request = null; } - headers = defaultHeaders; + headers = Object.assign({}, defaultHeaders); this.responseText = ""; this.responseXML = ""; + this.response = Buffer.alloc(0); - errorFlag = true; - + errorFlag = abortedFlag = true if (this.readyState !== this.UNSENT && (this.readyState !== this.OPENED || sendFlag) && this.readyState !== this.DONE) { @@ -608,35 +938,123 @@ function XMLHttpRequest(opts) { /** * Dispatch any events, including both "on" methods and events attached using addEventListener. */ - this.dispatchEvent = function(event) { + this.dispatchEvent = function (event) { + let argument = { type: event }; if (typeof self["on" + event] === "function") { - self["on" + event](); + if (this.readyState === this.DONE && settings.async) + setTimeout(function() { self["on" + event](argument) }, 0) + else + self["on" + event](argument) } if (event in listeners) { - for (var i = 0, len = listeners[event].length; i < len; i++) { - listeners[event][i].call(self); + for (let i = 0, len = listeners[event].length; i < len; i++) { + if (this.readyState === this.DONE) + setTimeout(function() { listeners[event][i].call(self, argument) }, 0) + else + listeners[event][i].call(self, argument) } } }; + /** + * Construct the correct form of response, given responseType when in non-file based, asynchronous mode. + * + * When self.responseType is "", "text", "json", self.responseText is a utf8 string. + * When self.responseType is "arraybuffer", "blob", the response is in the buffers parameter, + * an Array of Buffers. Then concat(buffers) is Uint8Array, from which checkAndShrinkBuffer + * extracts the correct sized ArrayBuffer. + * + * @param {Array} buffers + */ + this.createResponse = function(buffers) { + self.responseXML = ''; + switch (self.responseType) { + case "": + case "text": + self.response = self.responseText; + break; + case 'json': + self.response = JSON.parse(self.responseText); + self.responseText = ''; + break; + default: + self.responseText = ''; + const totalResponse = concat(buffers); + // When self.responseType === 'arraybuffer', self.response is an ArrayBuffer. + // Get the correct sized ArrayBuffer. + self.response = checkAndShrinkBuffer(totalResponse); + if (self.responseType === 'blob' && typeof Blob === 'function') { + // Construct the Blob object that contains response. + self.response = new Blob([self.response]); + } + break; + } + } + + /** + * Construct the correct form of response, given responseType when in synchronous mode or file based. + * + * The input is the response parameter which is a Buffer. + * When self.responseType is "", "text", "json", + * the input is further refined to be: response.toString('utf8'). + * When self.responseType is "arraybuffer", "blob", + * the input is further refined to be: checkAndShrinkBuffer(response). + * + * @param {Buffer} response + */ + this.createFileOrSyncResponse = function(response) { + self.responseText = ''; + self.responseXML = ''; + switch (self.responseType) { + case "": + case "text": + self.responseText = response.toString('utf8'); + self.response = self.responseText; + break; + case 'json': + self.response = JSON.parse(response.toString('utf8')); + break; + default: + // When self.responseType === 'arraybuffer', self.response is an ArrayBuffer. + // Get the correct sized ArrayBuffer. + self.response = checkAndShrinkBuffer(response); + if (self.responseType === 'blob' && typeof Blob === 'function') { + // Construct the Blob object that contains response. + self.response = new Blob([self.response]); + } + break; + } + } + /** * Changes readyState and calls onreadystatechange. * * @param int state New state */ var setState = function(state) { - if (self.readyState !== state) { - self.readyState = state; + if ((self.readyState === state) || (self.readyState === self.UNSENT && abortedFlag)) + return - if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) { - self.dispatchEvent("readystatechange"); - } + self.readyState = state; - if (self.readyState === self.DONE && !errorFlag) { - self.dispatchEvent("load"); - // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) - self.dispatchEvent("loadend"); - } + if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) { + self.dispatchEvent("readystatechange"); + } + + if (self.readyState === self.DONE) { + let fire + + if (abortedFlag) + fire = "abort" + else if (errorFlag) + fire = "error" + else + fire = "load" + + self.dispatchEvent(fire) + + // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) + self.dispatchEvent("loadend"); } }; }; diff --git a/package.json b/package.json index 5cd17b8..8db9dfb 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,9 @@ { - "name": "xmlhttprequest", + "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "1.5.0", + "version": "3.1.0", "author": { - "name": "Dan DeFelippi", - "url": "http://driverdan.com" + "name": "Michael de Wit" }, "keywords": [ "xhr", @@ -18,16 +17,24 @@ ], "repository": { "type": "git", - "url": "git://github.com/driverdan/node-XMLHttpRequest.git" + "url": "git://github.com/mjwwit/node-XMLHttpRequest.git" }, - "bugs": "http://github.com/driverdan/node-XMLHttpRequest/issues", + "bugs": "http://github.com/mjwwit/node-XMLHttpRequest/issues", "engines": { - "node": ">=0.4.0" + "node": ">=12.0.0" + }, + "scripts": { + "test": "cd ./tests && node run-test.js" }, "directories": { "lib": "./lib", "example": "./example" }, + "files": [ + "lib/XMLHttpRequest.js", + "LICENSE", + "README.md" + ], "main": "./lib/XMLHttpRequest.js", "dependencies": {} } diff --git a/tests/run-test.js b/tests/run-test.js new file mode 100644 index 0000000..1e00421 --- /dev/null +++ b/tests/run-test.js @@ -0,0 +1,50 @@ +var ignored_files = [ + "run-test.js", // this file + "server.js" +]; + +var spawnSync = require("child_process").spawnSync; +var fs = require("fs"); +var path = require("path"); + +// global flag to check if some of test fails, and will store location of failed test file +var fail_path = false; + +// function to read and conduct test case +var run_test = function (file) { + if (fail_path) return; + // logging + console.log("Running:", file); + + // spawn a nodejs process + var proc = spawnSync("node", [file]); + + if (proc.status === 0) { + console.log(proc.stdout.toString()); + console.log("--> PASSED"); + } + else { + fail_path = file; + console.log("--> TEST FAILED - CAUGHT ERROR:", proc.stderr.toString()); + } +} + +var check_dir = function (dirPath) { + if (fail_path) return; + var files = fs.readdirSync(dirPath); + + for (var file of files) { + // return early in case something fails + if (fail_path) return; + var full_path = path.join(dirPath, file); + if (fs.statSync(full_path).isDirectory()) check_dir(full_path); + else if (path.extname(file) === ".js" && !ignored_files.includes(full_path)) run_test(full_path); + } +} + +// start test +check_dir("./"); + +if (fail_path) throw new Error("Test failed at file: " + fail_path); + +console.log("ALL TESTS PASSED."); diff --git a/tests/server.js b/tests/server.js new file mode 100644 index 0000000..e76752b --- /dev/null +++ b/tests/server.js @@ -0,0 +1,55 @@ +'use strict'; +var http = require("http"); + +var server = http.createServer(function (req, res) { + switch (req.url) { + case "/": { + var body = "Hello World"; + res.writeHead(200, { + "Content-Type": "text/plain", + "Content-Length": Buffer.byteLength(body), + "Date": "Thu, 30 Aug 2012 18:17:53 GMT", + "Connection": "close" + }); + res.end(body); + return; + } + case "/text": + res.writeHead(200, {"Content-Type": "text/plain"}) + res.end("Hello world!"); + return; + case "/xml": + res.writeHead(200, {"Content-Type": "application/xml"}) + res.end("Foobar"); + return; + case "/json": + res.writeHead(200, {"Content-Type": "application/json"}) + res.end(JSON.stringify({ foo: "bar" })); + return; + case "/binary1": + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(Buffer.from("Hello world!")); + return; + case "/binary2": + const ta = new Float32Array([1, 5, 6, 7]); + const buf = Buffer.from(ta.buffer); + const str = buf.toString('binary'); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(str); + return; + default: + if (req.url.startsWith('/redirectingResource/')) { + let remaining = req.url.replace(/^\/redirectingResource\/*/, "") - 1; + res.writeHead(301, {'Location': remaining ? ('http://localhost:8888/redirectingResource/' + remaining) : 'http://localhost:8888/'}); + res.end(); + } + else { + res.writeHead(404, {"Content-Type": "text/plain"}) + res.end("Not found"); + } + } +}).listen(8888); + +process.on("SIGINT", function () { + server.close(); +}); diff --git a/tests/test-constants.js b/tests/test-constants.js index 372e46c..57e1780 100644 --- a/tests/test-constants.js +++ b/tests/test-constants.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr = new XMLHttpRequest(); @@ -10,4 +9,4 @@ assert.equal(2, xhr.HEADERS_RECEIVED); assert.equal(3, xhr.LOADING); assert.equal(4, xhr.DONE); -sys.puts("done"); +console.log("done"); diff --git a/tests/test-data-uri.js b/tests/test-data-uri.js new file mode 100644 index 0000000..c94dd23 --- /dev/null +++ b/tests/test-data-uri.js @@ -0,0 +1,133 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , xhr; + +xhr = new XMLHttpRequest(); + +// define test data +var tests = [ + { + name: "Test plain URI Data", + data: "data:,Hello%20World", + output: "Hello World" + }, + { + name: "Test plain URI Data with spaces", + data: "data:, Hello World", + output: " Hello World" + }, + { + name: "Test plain URI Data with data URI headers", + data: "data:base64;example=1;args=2,Hello%20World", + output: "Hello World" + }, + { + name: "Test normal bass64-encoded data URI", + data: "data:text;base64,SGVsbG8gV29ybGQ=", + output: "Hello World" + }, + { + name: "Test normal bass64-encoded data URI with mixed space characters", + data: "data:text;base64,SGV sbG8gV\n29ybGQ=", + output: "Hello World" + }, + { + name: "Test normal bass64-encoded data URI with mixed space characters (url-encoded)", + data: "data:text;base64,SGV%20sbG8gV%0a29ybGQ=", + output: "Hello World" + }, + { + name: "Test normal bass64-encoded data URI with invalid characters", + data: "data:text;base64,SGV&&&&sbG8gV{29ybGQ=", + error: "Invalid data URI" + }, + { + name: "Test normal bass64-encoded data URI with invalid characters (url-encoded)", + data: "data:text;base64,SGV%26%26%26%26sbG8gV%7B29ybGQ%3D", + error: "Invalid data URI" + }, + { + name: "Test base64-encoded data with no paddings", + data: "data:text;base64,SGVsbG8gV29ybGQ", + output: "Hello World" + }, + { + name: "Test base64-encoded data with excessive paddings", + data: "data:text;base64,SGVsbG8gV29ybGQ==", + error: "Invalid data URI" + } +]; + +var tests_passed = 0; + +var runAsyncTest = function (test) { + console.log(" ASYNC"); + + xhr = new XMLHttpRequest; + xhr.open("get", test.data); + xhr.onreadystatechange = function () { + if (this.readyState === 4) { + if (test.error) { + assert.equal(xhr.status, 0); + assert.equal(xhr.statusText, test.error); + } + else { + assert.equal(xhr.status, 200); + assert.equal(xhr.responseText, test.output); + } + console.log(" --> SUCESS"); + ++tests_passed; + } + } + xhr.send(); +} + +var runSyncTest = function (test) { + console.log(" SYNC"); + + xhr = new XMLHttpRequest; + xhr.open("get", test.data, false); + try { + xhr.send(); + if (test.error) throw "Expected to fail, Success with " + e.responseText; + assert.equal(xhr.status, 200); + assert.equal(xhr.responseText, test.output); + } + catch (e) { + if (!test.error) throw "Expected to success, Caught error: " + e.toString() + assert.equal(xhr.status, 0); + assert.equal(e.message, test.error); + } + console.log(" --> SUCCESS"); + ++tests_passed; +} + +var i = 0; + +var startTest = function () { + let test = tests[i]; + + if (!test) { + console.log("Done:", tests_passed === tests.length * 2 ? "PASS" : "FAILED"); + return; + } + + console.log(test.name); + + runAsyncTest(test); + + setTimeout(function () { + try { + runSyncTest(test); + } + catch (e) { + console.error(e); + throw e; + }; + console.log(""); + ++i; + startTest(); + }, 500); +} + +startTest(); diff --git a/tests/test-disallow-fs-resources.js b/tests/test-disallow-fs-resources.js new file mode 100644 index 0000000..a244849 --- /dev/null +++ b/tests/test-disallow-fs-resources.js @@ -0,0 +1,39 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , xhr; + +xhr = new XMLHttpRequest({ allowFileSystemResources: false }); + +xhr.onreadystatechange = function() { + if (this.readyState === 4) { + assert.equal(this.statusText, "Not allowed to load local resource: " + url); + assert.equal(this.status, 0); + try { + runSync(); + } catch (e) { + if (e instanceof assert.AssertionError) { + throw e; + } + } + } +}; + +// Async +var url = "file://" + __dirname + "/testdata.txt"; +xhr.open("GET", url); +xhr.send(); + +// Sync +var runSync = function() { + xhr = new XMLHttpRequest({ allowFileSystemResources: false }); + + xhr.onreadystatechange = function() { + if (this.readyState === 4) { + assert.equal(this.statusText, "Not allowed to load local resource: " + url); + assert.equal(this.status, 0); + console.log("done"); + } + }; + xhr.open("GET", url, false); + xhr.send(); +} diff --git a/tests/test-events.js b/tests/test-events.js index c72f001..0d07fd7 100644 --- a/tests/test-events.js +++ b/tests/test-events.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , http = require("http") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr; @@ -20,7 +19,7 @@ var server = http.createServer(function (req, res) { assert.equal(onreadystatechange, true); assert.equal(readystatechange, true); assert.equal(removed, true); - sys.puts("done"); + console.log("done"); this.close(); }).listen(8000); diff --git a/tests/test-exceptions.js b/tests/test-exceptions.js index f1edd71..721ef72 100644 --- a/tests/test-exceptions.js +++ b/tests/test-exceptions.js @@ -1,6 +1,4 @@ -var sys = require("util") - , assert = require("assert") - , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest +var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr = new XMLHttpRequest(); // Test request methods that aren't allowed @@ -20,7 +18,8 @@ try { try { xhr.open("GET", "http://localhost:8000/"); } catch(e) { - console.log("ERROR: Invalid exception for GET", e); + console.error(e); + throw new Error("ERROR: Invalid exception for GET"); } // Test forbidden headers @@ -44,15 +43,12 @@ var forbiddenRequestHeaders = [ "trailer", "transfer-encoding", "upgrade", - "user-agent", "via" ]; for (var i in forbiddenRequestHeaders) { - try { - xhr.setRequestHeader(forbiddenRequestHeaders[i], "Test"); - console.log("ERROR: " + forbiddenRequestHeaders[i] + " should have thrown exception"); - } catch(e) { + if(xhr.setRequestHeader(forbiddenRequestHeaders[i], "Test") !== false) { + throw new Error("ERROR: " + forbiddenRequestHeaders[i] + " should have thrown exception"); } } diff --git a/tests/test-headers.js b/tests/test-headers.js index 76454f1..e22e5f9 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"); @@ -12,6 +11,8 @@ var server = http.createServer(function (req, res) { assert.equal("node-XMLHttpRequest-test", req.headers["user-agent"]); // Test header set with blacklist disabled assert.equal("http://github.com", req.headers["referer"]); + // Test case insensitive header was set + assert.equal("text/plain", req.headers["content-type"]); var body = "Hello World"; res.writeHead(200, { @@ -47,19 +48,22 @@ xhr.onreadystatechange = function() { assert.equal("", this.getAllResponseHeaders()); assert.equal(null, this.getResponseHeader("Connection")); - sys.puts("done"); + console.log("done"); } }; assert.equal(null, xhr.getResponseHeader("Content-Type")); try { - xhr.open("GET", "http://localhost:8000/"); + xhr.open("POST", "http://localhost:8000/"); + var body = "Hello World"; // Valid header xhr.setRequestHeader("X-Test", "Foobar"); // Invalid header - xhr.setRequestHeader("Content-Length", 0); + xhr.setRequestHeader("Content-Length", Buffer.byteLength(body)); // Allowed header outside of specs xhr.setRequestHeader("user-agent", "node-XMLHttpRequest-test"); + // Case insensitive header + xhr.setRequestHeader("content-type", 'text/plain'); // Test getRequestHeader assert.equal("Foobar", xhr.getRequestHeader("X-Test")); // Test invalid header @@ -70,7 +74,8 @@ try { xhr.setRequestHeader("Referer", "http://github.com"); assert.equal("http://github.com", xhr.getRequestHeader("Referer")); - xhr.send(); + xhr.send(body); } catch(e) { - console.log("ERROR: Exception raised", e); + console.error("ERROR: Exception raised", e); + throw e; } diff --git a/tests/test-keepalive.js b/tests/test-keepalive.js new file mode 100644 index 0000000..a5b09e7 --- /dev/null +++ b/tests/test-keepalive.js @@ -0,0 +1,37 @@ +var assert = require("assert"); +var http = require('http'); +var { XMLHttpRequest } = require("../lib/XMLHttpRequest"); + +var server = http.createServer({ keepAliveTimeout: 200 }, function handleConnection (req, res) { + res.write('hello\n'); + res.end(); +}).listen(8889); + +var agent = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 2000, +}); +var xhr = new XMLHttpRequest({ agent }); +var url = "http://localhost:8889"; + +var repeats = 0; +var maxMessages = 20; +var interval = setInterval(function sendRequest() { + xhr.open("GET", url); + xhr.onloadend = function(event) { + if (xhr.status !== 200) { + console.error('Error: non-200 xhr response, message is\n', xhr.responseText); + clearInterval(interval); + agent.destroy(); + server.close(); + assert.equal(xhr.status, 200); + } + if (repeats++ > maxMessages) { + console.log('Done.'); + clearInterval(interval); + agent.destroy(); + server.close(); + } + } + xhr.send(); +}, 200); \ No newline at end of file diff --git a/tests/test-max-redirects.js b/tests/test-max-redirects.js new file mode 100644 index 0000000..9938528 --- /dev/null +++ b/tests/test-max-redirects.js @@ -0,0 +1,47 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , spawn = require('child_process').spawn; + +// Test server +var serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +var runTest = function () { + try { + let xhr = new XMLHttpRequest({ maxRedirects: 10 }); + xhr.open("GET", "http://localhost:8888/redirectingResource/10", false); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + assert.equal(xhr.getRequestHeader('Location'), ''); + assert.equal(xhr.responseText, "Hello World"); + console.log("safe redirects count: done"); + } + }; + xhr.send(); + } catch(e) { + console.log("ERROR: Exception raised", e); + throw e; + } + + try { + let xhr = new XMLHttpRequest({ maxRedirects: 10 }); + xhr.open("GET", "http://localhost:8888/redirectingResource/20", false); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + assert.equal(xhr.statusText, 'Too many redirects'); + assert.equal(xhr.status, 0); + console.log("excessive redirects count: done"); + } + }; + xhr.send(); + } catch(e) { + assert.equal(e.message, 'Too many redirects'); + } +} + +setTimeout(function () { + try { + runTest(); + } finally { + serverProcess.kill('SIGINT'); + } +}, 100); diff --git a/tests/test-perf.js b/tests/test-perf.js new file mode 100644 index 0000000..b93fd97 --- /dev/null +++ b/tests/test-perf.js @@ -0,0 +1,239 @@ + +/****************************************************************************************** + * This test measurs the elapsed time to download a Float32Array of length 100,000,000. + */ +'use strict'; + +const http = require("http"); + +const XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; + +const supressConsoleOutput = false; +function log (_) { + if ( !supressConsoleOutput) + console.log.apply(console, arguments); +} + +var serverProcess; + +/****************************************************************************************** + * This section has various utility functions: + * 1) Create a random Float32Array of length N. + * 2) Efficiently concatenate the input Array of Buffers. + */ + +/** + * Create a random Float32Array of length N. + * @param {number} N + * @returns {Float32Array} + */ +function createFloat32Array (N) { + let ta = new Float32Array(N); + for (let k = 0; k < ta.length; k++) + ta[k] = Math.random(); + return ta; +} + +/** + * Efficiently concatenate the input Array of Buffers. + * Why not use Buffer.concat(...) ? + * Because bufTotal = Buffer.concat(...) often has byteOffset > 0, so bufTotal.buffer + * is larger than the useable region in bufTotal. + * @param {Array} bufferArray + * @returns + */ +function concat (bufferArray) { + var length = 0, offset = 0, k; + for (k = 0; k < bufferArray.length; k++) + length += bufferArray[k].length; + const result = Buffer.alloc(length); + for (k = 0; k < bufferArray.length; k++) + { + result.set(bufferArray[k], offset); + offset += bufferArray[k].length; + } + return result; +}; + +/****************************************************************************************** + * This section produces a web server that serves up anything uploaded. + * The uploaded data is stored as values in a storage object, where the keys are the upload url suffixes. + * E.g. storage['/F32'] === Buffer containing the corresponding upload. + */ + +const storage = { ralph: [1,2] }; + +function storageLength () { + const result = {}; + for (const key in storage) + result[key] = storage[key].length; + return result; +} +function checkStorage () { + log('storage:', JSON.stringify(storageLength())); +} + +/** + * mini-webserver: Serves up anything uploaded. + * Tested with: + * const urlXml = "http://localhost:8888/Xml"; + */ +function createServer() { + serverProcess = http.createServer(function (req, res) { + req.on('error', err => { console.error('request:', err) }); + res.on('error', err => { console.error('response:', err) }); + if (req.method === 'POST') { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const u8 = concat(chunks); + storage[req.url] = u8; + // console.log('server end-handler', req.url, u8.length, req.headers); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(`success:len ${u8.length}`); + }); + } else { + if (!storage[req.url]) { + res.writeHead(404, {"Content-Type": "text/plain; charset=utf8"}) + res.end("Not in storage"); + } + else { + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(storage[req.url]); + } + } + }).listen(8888); + process.on("SIGINT", function () { + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }); +} +createServer(); + +/****************************************************************************************** + * This section creates: + * 1) An upload function that POSTs using xmlhttprequest-ssl. + * 2) A download function that GETs using xmlhttprequest-ssl and allows sepcifying xhr.responseType. + */ + +function upload(xhr, url, data) { + return new Promise((resolve, reject) => { + xhr.open("POST", url, true); + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + resolve(xhr.responseText); + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.setRequestHeader('Content-Type', 'multipart/form-data'); // Unnecessary. + xhr.send(data); + }); +} + +function download (xhr, url, responseType) +{ + responseType = responseType || 'arraybuffer'; + return new Promise((resolve, reject) => { + xhr.open("GET", url, true); + + xhr.responseType = responseType; + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + { + switch (responseType) + { + case "": + case "text": + resolve(xhr.responseText); + break; + case "document": + resolve(xhr.responseXML); + break; + default: + resolve(xhr.response); + break; + } + } + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.send(); + }); +} + +/****************************************************************************************** + * This section: + * 1) Uploads random float32 array array of length 100,000,000. . + * 2) Downloads the float32 array and measures the download elpased time. + */ + +const N = 100 * 1000 * 1000; +const _f32 = createFloat32Array(N); + +const F32 = Buffer.from(_f32.buffer); + +const urlF32 = "http://localhost:8888/F32"; + +const xhr = new XMLHttpRequest(); +var handle, success, _t0; + +/** + * 1) Upload Float32Array of length N=100,000,000. + * Then download using xhr.responseType="arraybuffer" and check the the array lengths are the same. + */ +function runTest() { + let r = upload(xhr, urlF32, F32); // big + return r.then(afterUpload) +} + +function afterUpload(r) { + log('upload urlF32, F32 ', r); + + log('-----------------------------------------------------------------------------------'); + checkStorage(); // Check what's in the mini-webserver storage. + log('-----------------------------------------------------------------------------------'); + + _t0 = Date.now(); + success = true; + handle = setTimeout(() => { + console.error('Download has taken longer than 5 seconds and hence it has failed!'); + success = false; + }, 5 * 1000) + const ab = download(xhr, urlF32, 'arraybuffer'); // big + return ab.then(afterDownload); +} + +function afterDownload(ab) { + clearTimeout(handle); + console.log(`Download elapsed time:, ${Date.now() - _t0}ms`, ab.byteLength); + console.info('...waiting to see elapsed time of download...'); + if (!success) + throw new Error("Download has taken far too long!"); +} + +/** + * Run the test. + * If runTest() fails, an exception will be thrown. + */ +setTimeout(function () { + runTest() + .then(() => { console.log("PASSED"); shutdown(); }) + .catch((e) => { console.log("FAILED", e); shutdown(); throw e; }); +}, 100); + +function shutdown() { + if (serverProcess) + serverProcess.close(); + serverProcess = null; +} diff --git a/tests/test-redirect-301.js b/tests/test-redirect-301.js new file mode 100644 index 0000000..91ec4cf --- /dev/null +++ b/tests/test-redirect-301.js @@ -0,0 +1,41 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , xhr = new XMLHttpRequest() + , http = require("http"); + +// Test server +var server = http.createServer(function (req, res) { + if (req.url === '/redirectingResource') { + res.writeHead(301, {'Location': 'http://localhost:8000/'}); + res.end(); + return; + } + + var body = "Hello World"; + res.writeHead(200, { + "Content-Type": "text/plain", + "Content-Length": Buffer.byteLength(body), + "Date": "Thu, 30 Aug 2012 18:17:53 GMT", + "Connection": "close" + }); + res.write("Hello World"); + res.end(); + + this.close(); +}).listen(8000); + +xhr.onreadystatechange = function() { + if (this.readyState === 4) { + assert.equal(xhr.getRequestHeader('Location'), ''); + assert.equal(xhr.responseText, "Hello World"); + console.log("done"); + } +}; + +try { + xhr.open("GET", "http://localhost:8000/redirectingResource"); + xhr.send(); +} catch(e) { + console.log("ERROR: Exception raised", e); + throw e; +} diff --git a/tests/test-redirect-302.js b/tests/test-redirect-302.js index d884f78..802e948 100644 --- a/tests/test-redirect-302.js +++ b/tests/test-redirect-302.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"); @@ -26,10 +25,10 @@ var server = http.createServer(function (req, res) { }).listen(8000); xhr.onreadystatechange = function() { - if (this.readyState == 4) { + if (this.readyState === 4) { assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); - sys.puts("done"); + console.log("done"); } }; @@ -38,4 +37,5 @@ try { xhr.send(); } catch(e) { console.log("ERROR: Exception raised", e); + throw e; } diff --git a/tests/test-redirect-303.js b/tests/test-redirect-303.js index 60d9343..4d51962 100644 --- a/tests/test-redirect-303.js +++ b/tests/test-redirect-303.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"); @@ -26,10 +25,10 @@ var server = http.createServer(function (req, res) { }).listen(8000); xhr.onreadystatechange = function() { - if (this.readyState == 4) { + if (this.readyState === 4) { assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); - sys.puts("done"); + console.log("done"); } }; @@ -38,4 +37,5 @@ try { xhr.send(); } catch(e) { console.log("ERROR: Exception raised", e); + throw e; } diff --git a/tests/test-redirect-307.js b/tests/test-redirect-307.js index 3abc906..6e8cb9f 100644 --- a/tests/test-redirect-307.js +++ b/tests/test-redirect-307.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"); @@ -28,10 +27,10 @@ var server = http.createServer(function (req, res) { }).listen(8000); xhr.onreadystatechange = function() { - if (this.readyState == 4) { + if (this.readyState === 4) { assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); - sys.puts("done"); + console.log("done"); } }; @@ -40,4 +39,5 @@ try { xhr.send(); } catch(e) { console.log("ERROR: Exception raised", e); + throw e; } diff --git a/tests/test-redirect-308.js b/tests/test-redirect-308.js new file mode 100644 index 0000000..d517f68 --- /dev/null +++ b/tests/test-redirect-308.js @@ -0,0 +1,41 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , xhr = new XMLHttpRequest() + , http = require("http"); + +// Test server +var server = http.createServer(function (req, res) { + if (req.url === '/redirectingResource') { + res.writeHead(308, {'Location': 'http://localhost:8000/'}); + res.end(); + return; + } + + var body = "Hello World"; + res.writeHead(200, { + "Content-Type": "text/plain", + "Content-Length": Buffer.byteLength(body), + "Date": "Thu, 30 Aug 2012 18:17:53 GMT", + "Connection": "close" + }); + res.write("Hello World"); + res.end(); + + this.close(); +}).listen(8000); + +xhr.onreadystatechange = function() { + if (this.readyState === 4) { + assert.equal(xhr.getRequestHeader('Location'), ''); + assert.equal(xhr.responseText, "Hello World"); + console.log("done"); + } +}; + +try { + xhr.open("GET", "http://localhost:8000/redirectingResource"); + xhr.send(); +} catch(e) { + console.log("ERROR: Exception raised", e); + throw e; +} diff --git a/tests/test-request-methods.js b/tests/test-request-methods.js index fa1b1be..275a5d7 100644 --- a/tests/test-request-methods.js +++ b/tests/test-request-methods.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , http = require("http") , xhr; @@ -24,7 +23,7 @@ var server = http.createServer(function (req, res) { if (curMethod == methods.length - 1) { this.close(); - sys.puts("done"); + console.log("done"); } }).listen(8000); @@ -47,7 +46,7 @@ function start(method) { curMethod++; if (curMethod < methods.length) { - sys.puts("Testing " + methods[curMethod]); + console.log("Testing " + methods[curMethod]); start(methods[curMethod]); } } @@ -58,5 +57,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-binary-data.js b/tests/test-request-protocols-binary-data.js new file mode 100644 index 0000000..1a10344 --- /dev/null +++ b/tests/test-request-protocols-binary-data.js @@ -0,0 +1,90 @@ +/** + * Test GET file URL with both async and sync mode. + * Use xhr.responseType = "arraybuffer". + */ +'use strict'; +var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + +const supressConsoleOutput = true; +function log (_) { + if ( !supressConsoleOutput) + console.log(arguments); +} + +var url = "file://" + __dirname + "/testBinaryData"; + +function download (url, isAsync) { + if (isAsync === undefined) + isAsync = true; + var xhr = new XMLHttpRequest(); + return new Promise((resolve, reject) => { + xhr.open("GET", url, true); + + xhr.responseType = 'arraybuffer'; + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + resolve(xhr.response); + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.send(); + }); +} + +function runTest () { + // Async + var ab = download(url, /*isAsync*/ true); + return ab.then(afterAsyncDownload); +} + +function afterAsyncDownload(ab) { + var str = Buffer.from(ab).toString('binary'); + var strLog = logBinary(str); + log('async phase', strLog); + if ("0000 803f 0000 a040 0000 c040 0000 e040" !== strLog) + throw new Error(`Failed test-request-protocols-binary-data async phase: "0000 803f 0000 a040 0000 c040 0000 e040" !== ${strLog}`); + log("done async phase"); + + // Sync + var abSync = download(url, /*isAsync*/ false); + return abSync.then(afterSyncDownload); +} + +function afterSyncDownload(abSync) { + var str = Buffer.from(abSync).toString('binary'); + var strLog = logBinary(str); + log('sync phase', strLog); + if ("0000 803f 0000 a040 0000 c040 0000 e040" !== strLog) + throw new Error(`Failed test-request-protocols-binary-data sync phase: "0000 803f 0000 a040 0000 c040 0000 e040" !== ${strLog}`); + log("done sync phase"); +} + +runTest() + .then(() => console.log('PASSED')) + .catch((e) => { console.error('FAILED'); throw e; }); + +function logBinary(data) { + function log(data, idx) { + const char = data.charCodeAt(idx).toString(16); + // node compatibility: padStart doesn't exist to make sure return is 2 characters + if (char.length === 1) + return '0' + char; + else + return char; + } + if (!data) return 'no data'; + if (typeof data !== 'string') return 'not a string'; + let str = ''; + for (let k = 0; k < data.length - 2; k += 2) + str += `${log(data, k)}${log(data, k+1)} `; + if ((data.length % 2) == 0) + str += `${log(data, data.length - 2)}${log(data, data.length - 1)}`; + else + str += `${log(data, data.length - 1)}`; + return str; +} diff --git a/tests/test-request-protocols.js b/tests/test-request-protocols-txt-data.js similarity index 83% rename from tests/test-request-protocols.js rename to tests/test-request-protocols-txt-data.js index cd4e174..8164333 100644 --- a/tests/test-request-protocols.js +++ b/tests/test-request-protocols-txt-data.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr; @@ -8,7 +7,6 @@ xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal("Hello World", this.responseText); - this.close(); runSync(); } }; @@ -25,8 +23,7 @@ var runSync = function() { xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal("Hello World", this.responseText); - this.close(); - sys.puts("done"); + console.log("done"); } }; xhr.open("GET", url, false); diff --git a/tests/test-response-type.js b/tests/test-response-type.js new file mode 100644 index 0000000..6b4f39f --- /dev/null +++ b/tests/test-response-type.js @@ -0,0 +1,400 @@ + +/****************************************************************************************** + * This test validates xhr.responseType as described by: + * section 3.6, subsections 8,9,10,11 of https://xhr.spec.whatwg.org/#the-response-attribute + * except xhr.responseType='document' is not yet supported. + * + * 1) Create a simple min-webserver using the node http module. + * 2) Upload 2 different float32 arrays . + * 3) Upload the utf8 encoding of the underlying in-memory representations of 1). + * 4) Upload a stringified JSON object. + * 5) Then these 5 different uploads are downloaded as xhr.reponseType varies over + * [ "text", "", "arraybuffer", "blob", "json" ] + * and then various checks verify that the downloaded content is the same as that uploaded. + */ +'use strict'; + +const http = require("http"); +const XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; + +const supressConsoleOutput = true; +function log (_) { + if ( !supressConsoleOutput) + console.log(arguments); +} + +var serverProcess; + +/****************************************************************************************** + * This section has various utility functions: + * 1) Convert typed array to binary string identical to underlying in-memory representation. + * 2) Convert string to typed array when the string is the in-memory representation of a Float32Array. + * 3) Display the underlying in-memory representation of the input string data. + * 4) Pause/sleep for t milliseconds. + * 5) Create a random Float32Array of length N. + * 6) Check to see if 2 array-like objects have the same elements. + * 7) Efficiently concatenate the input Array of Buffers. + */ + +/** + * Create a string corresponding to the in-memory representation of typed array ta. + * @param {{ buffer: ArrayBuffer, length: number }} ta + * @returns {string} + */ +function typedArrayToString (ta) { + const u8 = new Uint8Array(ta.buffer); + return u8.reduce((acc, cur) => acc + String.fromCharCode(cur), ""); +} + +/** + * Assumes str is the in-memory representation of a Float32Array. + * Relies on the fact that the char codes in str are all <= 0xFF. + * Returns Float32Array corresponding to str. + * + * @param {string} str + * @returns {Float32Array} + */ +function stringToFloat32Array (str) { + const u8 = new Uint8Array(str.length); + for (let k = 0; k < str.length; k++) + u8[k] = Number(str.charCodeAt(k)); + return new Float32Array(u8.buffer); +} + +/** + * Create a random Float32Array of length N. + * @param {number} N + * @returns {Float32Array} + */ +function createFloat32Array (N) { + let ta = new Float32Array(N); + for (let k = 0; k < ta.length; k++) + ta[k] = Math.random(); + return ta; +} + +/** + * Check to see if 2 array-like objects have the same elements. + * @param {{ length: number }} ar1 + * @param {{ length: number }} ar2 + * @returns {boolean} + */ +function isEqual (ar1, ar2) { + if (ar1.length !== ar2.length) + return false; + for (let k = 0; k < ar1.length; k++) + if (ar1[k] !== ar2[k]) + return false; + return true; +} + +/** + * Efficiently concatenate the input Array of Buffers. + * Why not use Buffer.concat(...) ? + * Because bufTotal = Buffer.concat(...) often has byteOffset > 0, so bufTotal.buffer + * is larger than the useable region in bufTotal. + * @param {Array} bufferArray + * @returns + */ +function concat (bufferArray) { + var length = 0, offset = 0, k; + for (k = 0; k < bufferArray.length; k++) + length += bufferArray[k].length; + const result = Buffer.alloc(length); + for (k = 0; k < bufferArray.length; k++) + { + bufferArray[k].copy(result, offset, 0, bufferArray[k].length) + offset += bufferArray[k].length; + } + return result; +}; + +/****************************************************************************************** + * This section produces a web server that serves up anything uploaded. + * The uploaded data is stored as values in a storage object, where the keys are the upload url suffixes. + * E.g. storage['/F32'] === Buffer containing the corresponding upload. + */ + +const storage = { ralph: [1,2] }; + +function storageLength () { + const result = {}; + for (const key in storage) + if (key !== '/Json') // json not stored when uploading, but is stored when retrieving, new key makes check fail + result[key] = storage[key].length; + return result; +} +function checkStorage () { + log('-----------------------------------------------------------------------------------'); + log('storage:', JSON.stringify(storageLength())); + log('-----------------------------------------------------------------------------------'); +} + +// Xml doc for testing responseType "document" +const xmlDoc = +'' ++' test' ++' ' ++''; + +/** + * Serves up anything uploaded. + * Tested with: + * const urlF32 = "http://localhost:8888/F32"; + * const urlF32_2 = "http://localhost:8888/F32_2"; + * const urlUtf8 = "http://localhost:8888/Utf8"; + * const urlUtf8_2 = "http://localhost:8888/Utf8_2"; + * const urlJson = "http://localhost:8888/Json"; + * const urlXml = "http://localhost:8888/Xml"; + */ +function createServer() { + serverProcess = http.createServer(function (req, res) { + req.on('error', err => { console.error('request:', err) }); + res.on('error', err => { console.error('response:', err) }); + if (req.method === 'POST') { + const chunks = []; + //req.on('data', chunk => chunks.push(chunk)); + req.on('data', chunk => { + // console.log('foo', chunk.toString('utf8')); + // console.log('bar', JSON.parse(chunk.toString('utf8'))); + // console.log('bar', unescape(chunk.toString('utf8'))); + chunks.push(chunk); + }); + req.on('end', () => { + const u8 = concat(chunks); + storage[req.url] = u8; + // console.log('server end-handler', req.url, u8.length, req.headers); + // console.log(u8.toString('utf8')); + // console.log('-------------------'); + // console.log(xmlDoc); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(`success:len ${u8.length}`); + }); + } else { + if (!storage[req.url]) + { + res.writeHead(404, {"Content-Type": "text/plain; charset=utf8"}) + res.end("Not in storage"); + return; + } + if (req.url === "/Utf8" || req.url === "/Utf8_2" || req.url === "/Json" || req.url === "/Xml") + { + res.writeHead(200, {"Content-Type": "text/plain; charset=utf8"}) + res.end(storage[req.url].toString()); + return; + } + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(storage[req.url]); + } + }).listen(8888); + process.on("SIGINT", function () { + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }); +} +createServer(); + +/****************************************************************************************** + * This section creates: + * 1) An upload function that POSTs using xmlhttprequest-ssl. + * 2) A download function that GETs using xmlhttprequest-ssl and allows sepcifying xhr.responseType. + */ + +function upload(xhr, url, data) { + return new Promise((resolve, reject) => { + xhr.open("POST", url, true); + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + resolve(xhr.responseText); + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.setRequestHeader('Content-Type', 'multipart/form-data'); // Unnecessary. + xhr.send(data); + }); +} + +function download (xhr, url, responseType) +{ + responseType = responseType || 'arraybuffer'; + return new Promise((resolve, reject) => { + xhr.open("GET", url, true); + + xhr.responseType = responseType; + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + { + switch (responseType) + { + case "": + case "text": + resolve(xhr.responseText); + break; + case "document": + resolve(xhr.responseXML); + break; + default: + resolve(xhr.response); + break; + } + } + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.send(); + }); +} + +/****************************************************************************************** + * This section: + * 1) Uploads 2 different float32 arrays . + * 2) Uploads the utf8 encoding of the underlying in-memory representations of 1). + * 3) Uploads a stringified JSON object. + * 4) Then these 5 different uploads are downloaded as xhr.reponseType varies over + * [ "text", "", "arraybuffer", "blob", "json" ] + * and then various checks verify that the downloaded content is the same as that uploaded. + */ + +const N = 1 * 1000 * 1000; +const _f32 = createFloat32Array(N); +const _f32_2 = new Float32Array([ 1, 5, 6, 7, 2, 8 ]); + +const F32 = Buffer.from(_f32.buffer); +const F32_2 = Buffer.from(_f32_2.buffer); +const F32Utf8 = Buffer.from(typedArrayToString(_f32), 'utf8'); +const F32Utf8_2 = Buffer.from(typedArrayToString(_f32_2), 'utf8'); + +const urlF32 = "http://localhost:8888/F32"; +const urlF32_2 = "http://localhost:8888/F32_2"; +const urlUtf8 = "http://localhost:8888/Utf8"; +const urlUtf8_2 = "http://localhost:8888/Utf8_2"; +const urlJson = "http://localhost:8888/Json"; + +const xhr = new XMLHttpRequest(); + +const type = (o) => { return `type=${o && o.constructor && o.constructor.name}`; }; + +/** + * 1) Upload Float32Array of length N=1,000,000. + * Then download using xhr.responseType="arraybuffer" and check the the array lengths are the same. + * 2) Convert the Float32Array of 1) into a string, utf8 encode it and upload it. + * Then download using xhr.responseType="text" and check the the string length is the same as the + * byteLength of the array in 1). Downloading as "text" decodes the utf8 into the original. + * 3) Upload Float32Array([1, 5, 6, 7, 2, 8]). + * Then download using xhr.responseType="blob", extract the contained arrayBuffer, view it as + * a Float32Aray and check that the contents are identical. + * 4) Convert the Float32Array of 3) into a string, utf8 encode it and upload it. + * Then download using xhr.responseType="" and check the the string length is the same as the + * byteLength of the array in 3). Downloading as "" decodes the utf8 into the original. + * 5) Let testJson be the current mini-webserver storage object: + * e.g. testJson = {ralph:2,'/F32':4000000,'/Utf8':5333575,'/F32_2':24,'/Utf8_2':28,'/Xml':56,'/Json':77} + * Upload JSON.stringify(testJson) and download it using xhr.responseType="json" + * Check that the objects are the same by comparing the strings after calling JSON.stringify. + * 6) Did a test of xhr.responseType="document" using a simple xml example. + */ +function runTest() { + const uploadPromises = []; + var r; + return upload(xhr, urlF32, F32) // upload float32 + .then((r) => { + log('upload urlF32, F32 ', r); + }) + .then(() => { // download float32 + return download(xhr, urlF32, 'arraybuffer'); + }) + .then((ab) => { // make sure download is correct + const f32 = new Float32Array(ab); + log('download urlF32 arraybuf', f32.byteLength, type(ab)); + if (f32.byteLength !== F32.length) + throw new Error(`Download from urlF32 has incorrect length: ${f32.byteLength} !== ${F32.length}`); + }) + .then(() => { + return upload(xhr, urlUtf8, F32Utf8); + }) + .then((r) => { + log('upload urlUtf8, F32Utf8 ', r); + }) + .then(() => { + return download(xhr, urlF32, 'arraybuffer'); + }) + .then((ab) => { + const f32 = new Float32Array(ab); + log('download urlF32 arraybuf', f32.byteLength, type(ab)); + if (f32.byteLength !== F32.length) + throw new Error(`Download from urlF32 has incorrect length: ${f32.byteLength} !== ${F32.length}`); + }) + .then(() => { + return upload(xhr, urlF32_2, F32_2); + }) + .then((r) => { + log('upload urlF32_2, F32_2 ', r); + }) + .then(() => { + return download(xhr, urlF32, 'arraybuffer'); + }) + .then((ab) => { + const f32 = new Float32Array(ab) + log('download urlF32 arraybuf', f32.byteLength, type(ab)); + if (f32.byteLength !== F32.length) + throw new Error(`Download from urlF32 has incorrect length: ${f32.byteLength} !== ${F32.length}`); + }) + .then(() => { + log('XXXXXXXXXXXXXXXXX', urlUtf8_2, F32Utf8_2) + return upload(xhr, urlUtf8_2, F32Utf8_2); + }) + .then((r) => { + log('upload urlUtf8_2, F32Utf8_2', r); + }) + .then(() => { + return download(xhr, urlUtf8_2, 'text'); + }) + .then((text2) => { + const text2_f32 = stringToFloat32Array(text2); + log('download urlUtf8_2 default', text2.length, type(text2), text2_f32); + if (!isEqual(text2_f32, _f32_2)) + throw new Error(`Download from urlUtf8_2 has incorrect content: ${text2_f32} !== ${_f32_2}`); + }) + .then(() => { + return upload(xhr, urlJson, JSON.stringify(storageLength())); + }) + .then((r) => { + log('upload:urlJson, storage ', r); + }) + .then(() => { + return download(xhr, urlJson, 'json'); + }) + .then((json) => { + log(`download urlJson json ${JSON.stringify(json).length}`, type(json), json); + const testJson = storageLength(); + if (JSON.stringify(json) !== JSON.stringify(testJson)) + throw new Error(`Download from urlJson has incorrect content:\n ${JSON.stringify(json)} !== ${JSON.stringify(testJson)}`); + }); + +} + +/** + * Run the test. + * If runTest() fails, an exception will be thrown. + */ +setTimeout(function () { + runTest() + .then(() => { console.log("PASSED"); shutdown(); }) + .catch((e) => { console.log("FAILED", e); shutdown(); throw e; }); +}, 100); + +function shutdown() { + if (serverProcess) + serverProcess.close(); + serverProcess = null; +} diff --git a/tests/test-sync-response.js b/tests/test-sync-response.js new file mode 100644 index 0000000..734fe01 --- /dev/null +++ b/tests/test-sync-response.js @@ -0,0 +1,142 @@ +/** + * Test GET http URL with both async and sync mode. + * Use xhr.responseType = "" and "arraybuffer". + */ +'use strict'; + +var assert = require("assert") + , spawn = require('child_process').spawn + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , serverProcess; + +const supressConsoleOutput = true; +function log (_) { + if ( !supressConsoleOutput) + console.log.apply(console, arguments); +} + +// Running a sync XHR and a webserver within the same process will cause a deadlock +serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +setTimeout(function () { + try { + runTest(); + console.log('PASSED'); + } catch (e) { + console.log('FAILED'); + throw e; + } finally { + serverProcess.kill('SIGINT'); + } +}, 100); + +/** + * Assumes hexStr is the in-memory representation of a Float32Array. + * Relies on the fact that the char codes in hexStr are all <= 0xFF. + * Returns Float32Array corresponding to hexStr. + * + * @param {string} hexStr + * @returns {Float32Array} + */ +function stringToFloat32Array (hexStr) { + const u8 = new Uint8Array(hexStr.length); + for (let k = 0; k < hexStr.length; k++) + u8[k] = Number(hexStr.charCodeAt(k)); + return new Float32Array(u8.buffer); +} + +/** + * Check to see if 2 array-like objects have the same elements. + * @param {{ length: number }} ar1 + * @param {{ length: number }} ar2 + * @returns {boolean} + */ +function isEqual (ar1, ar2) { + if (ar1.length !== ar2.length) + return false; + for (let k = 0; k < ar1.length; k++) + if (ar1[k] !== ar2[k]) + return false; + return true; +} + +function runTest() { + var xhr = new XMLHttpRequest(); + var isSync = false; + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + // xhr.responseText is a 'utf8' string. + var str = xhr.responseText; + log('/text', str); + assert.equal(xhr.responseText, "Hello world!"); + assert.equal(xhr.getResponseHeader('content-type'), 'text/plain') + isSync = true; + } + } + + xhr.open("GET", "http://localhost:8888/text", false); + xhr.send(); + + assert(isSync, "XMLHttpRequest was not synchronous"); + + xhr = new XMLHttpRequest(); + isSync = false; + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + // xhr.response is an ArrayBuffer + var str = Buffer.from(xhr.response).toString('utf8'); + log('/binary1', str); + assert.equal(str, 'Hello world!'); + assert.equal(xhr.getResponseHeader('content-type'), 'application/octet-stream') + isSync = true; + } + } + + xhr.open("GET", "http://localhost:8888/binary1", false); + xhr.responseType = 'arraybuffer'; + xhr.send(); + + assert(isSync, "XMLHttpRequest was not synchronous"); + + xhr = new XMLHttpRequest(); + isSync = false; + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + // xhr.response is an ArrayBuffer + var binaryStr = Buffer.from(xhr.response).toString('binary'); + var f32 = stringToFloat32Array(binaryStr); + log('/binary2', f32); + var answer = new Float32Array([1, 5, 6, 7]); + assert.equal(isEqual(f32, answer), true); + assert.equal(xhr.getResponseHeader('content-type'), 'application/octet-stream') + isSync = true; + } + } + + xhr.open("GET", "http://localhost:8888/binary2", false); + xhr.responseType = 'arraybuffer'; + xhr.send(); + + assert(isSync, "XMLHttpRequest was not synchronous"); + + xhr = new XMLHttpRequest(); + isSync = false; + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + assert.equal(xhr.response.toString(), 'Hello world!'); + assert.equal(xhr.getResponseHeader('content-type'), 'application/octet-stream') + isSync = true; + } + } + + xhr.open("GET", "http://localhost:8888/binary1", false); + xhr.send(); + + assert(isSync, "XMLHttpRequest was not synchronous"); + + console.log("done"); +} diff --git a/tests/test-unsafe-redirect.js b/tests/test-unsafe-redirect.js new file mode 100644 index 0000000..065d808 --- /dev/null +++ b/tests/test-unsafe-redirect.js @@ -0,0 +1,41 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , xhr = new XMLHttpRequest() + , http = require("http"); + +// Test server +var server = http.createServer(function (req, res) { + if (req.url === '/redirectingResource') { + res.writeHead(301, {'Location': 'file:///etc/passwd'}); + res.end(); + } + else { + var body = "Hello World"; + res.writeHead(200, { + "Content-Type": "text/plain", + "Content-Length": Buffer.byteLength(body), + "Date": "Thu, 30 Aug 2012 18:17:53 GMT", + "Connection": "close" + }); + res.write("Hello World"); + res.end(); + } + + this.close(); +}).listen(8000); + +xhr.onreadystatechange = function() { + if (this.readyState === 4) { + assert.equal(xhr.statusText, "Unsafe redirect"); + assert.equal(xhr.status, 0); + console.log("done"); + } +}; + +try { + xhr.open("GET", "http://localhost:8000/redirectingResource"); + xhr.send(); +} catch(e) { + console.log("ERROR: Exception raised", e); + throw e; +} diff --git a/tests/test-url-origin.js b/tests/test-url-origin.js new file mode 100644 index 0000000..ff89b2c --- /dev/null +++ b/tests/test-url-origin.js @@ -0,0 +1,47 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , spawn = require('child_process').spawn; + +// Test server +var serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +var runTest = function () { + try { + let xhr = new XMLHttpRequest({ origin: "http://localhost:8888" }); + xhr.open("GET", "text", false); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + assert.equal(xhr.getResponseHeader('Content-Type'), 'text/plain'); + assert.equal(xhr.responseText, "Hello world!"); + console.log("origin test 1: done"); + } + }; + xhr.send(); + } catch(e) { + console.log("ERROR: Exception raised", e); + } + + try { + let xhr = new XMLHttpRequest({ origin: "http://localhost:8888/text" }); + xhr.open("GET", "", false); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + assert.equal(xhr.getResponseHeader('Content-Type'), 'text/plain'); + assert.equal(xhr.responseText, "Hello world!"); + console.log("origin test 2: done"); + } + }; + xhr.send(); + } catch(e) { + console.log("ERROR: Exception raised", e); + throw e; + } +} + +setTimeout(function () { + try { + runTest(); + } finally { + serverProcess.kill('SIGINT'); + } +}, 100); diff --git a/tests/test-utf8-tearing.js b/tests/test-utf8-tearing.js new file mode 100644 index 0000000..79a6d46 --- /dev/null +++ b/tests/test-utf8-tearing.js @@ -0,0 +1,276 @@ + +/****************************************************************************************** + * Assume a web server serves up the utf8 encoding of a random Uint8Array, + * so that xhr.responseText is a string corresponding to the in-memory + * representation of the Uint8Array. This test demonstrates a bug in xmlhttprequest-ssl, + * where the utf8 endcoding of a byte with 0x80 <= byte <= 0xff, is torn across 2 chunks. + * + * Consider a code point 0x80. The utf8 encoding has 2 bytes 0xc2 and 0x80. + * It is possible for one chunk to end with 0xc2 and the next chunk starts with 0x80. + * This is what is meant by tearing. The fix is to remove + * self.responseText += data.toString('utf8'); + * from the response 'data' handler and add the following to the response 'end' handler + * // Construct responseText from response + * self.responseText = self.response.toString('utf8'); + */ +'use strict'; + +var assert = require("assert"); +var http = require("http"); +var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; + +var supressConsoleOutput = true; +function log (_) { + if ( !supressConsoleOutput) + console.log(arguments); +} + +var serverProcess; + +/****************************************************************************************** + * This section produces a web server that serves up + * 1) Buffer.from(ta.buffer) using url = "http://localhost:8888/binary"; + * 2) utf8 encoding of ta_to_hexStr(ta) using url = "http://localhost:8888/binaryUtf8"; + * where ta is a Float32Array. + * Note: In order to repro utf8 tearing ta.length needs to be pretty big + * N = 1 * 1000 * 1000; + */ + +/** + * Create a string corresponding to the in-memory representation of Float32Array ta. + * + * @param {Float32Array} ta + * @returns {string} + */ +function ta_to_hexStr(ta) { + var u8 = new Uint8Array(ta.buffer); + return u8.reduce(function (acc, cur) { return acc + String.fromCharCode(cur) }, ""); +} + +/** + * Create a random Float32Array of length N. + * + * @param {number} N + * @returns {Float32Array} + */ +function createFloat32Array(N) { + assert(N > 0); + var ta = new Float32Array(N); + for (var k = 0; k < ta.length; k++) + ta[k] = Math.random(); + //ta = new Float32Array([1, 5, 6, 7]); // Use to debug + return ta; +} +var N = 1 * 1000 * 1000; // Needs to be big enough to tear a few utf8 sequences. +var f32 = createFloat32Array(N); + +/** + * From a Float32Array f32 transform into: + * 1) buffer: Buffer.from(ta.buffer) + * 2) bufferUtf8: utf8 encoding of ta_to_hexStr(ta) + * + * @param {Float32Array} f32 + * @returns {{ buffer: Buffer, bufferUtf8: Buffer }} + */ +function createBuffers(f32) { + var buffer = Buffer.from(f32.buffer); + var ss = ta_to_hexStr(f32); + var bufferUtf8 = Buffer.from(ss, 'utf8'); // Encode ss in utf8 + return { buffer, bufferUtf8 }; +} +var bufs = createBuffers(f32); +var buffer = bufs.buffer, + bufferUtf8 = bufs.bufferUtf8 + +/** + * Serves up buffer at + * url = "http://localhost:8888/binary"; + * Serves up bufferUtf8 at + * url = "http://localhost:8888/binaryUtf8"; + * + * @param {Buffer} buffer + * @param {Buffer} bufferUtf8 + */ +function createServer(buffer, bufferUtf8) { + serverProcess = http.createServer(function (req, res) { + switch (req.url) { + case "/binary": + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(buffer); + return; + case "/binaryUtf8": + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(bufferUtf8); + return; + default: + res.writeHead(404, {"Content-Type": "text/plain"}) + res.end("Not found"); + return; + } + }).listen(8888); + process.on("SIGINT", function () { + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }); +} +createServer(buffer, bufferUtf8); + +/****************************************************************************************** + * This section tests the above web server and verifies the correct Float32Array can be + * successfully reconstituted for both + * 1) url = "http://localhost:8888/binary"; + * 2) url = "http://localhost:8888/binaryUtf8"; + */ + +/** + * Assumes hexStr is the in-memory representation of a Float32Array. + * Relies on the fact that the char codes in hexStr are all <= 0xFF. + * Returns Float32Array corresponding to hexStr. + * + * @param {string} hexStr + * @returns {Float32Array} + */ +function hexStr_to_ta(hexStr) { + var u8 = new Uint8Array(hexStr.length); + for (var k = 0; k < hexStr.length; k++) + u8[k] = Number(hexStr.charCodeAt(k)); + return new Float32Array(u8.buffer); +} + +/** + * Verify ta1 and ta2 are the same kind of view. + * Verify the first count elements of ta1 and ta2 are equal. + * + * @param {Float32Array} ta1 + * @param {Float32Array} ta2 + * @param {number} [count=1000] + * @returns {boolean} + */ +function checkEnough(ta1, ta2, count) { + if (count === undefined) + count = 1000 + assert(ta1 && ta2); + if (ta1.constructor.name !== ta2.constructor.name) return false; + if (ta1.length !== ta2.length) return false; + if (ta1.byteOffset !== ta2.byteOffset) return false; + for (var k = 0; k < Math.min(count, ta1.length); k++) { + if (ta1[k] !== ta2[k]) { + log('checkEnough: Not Equal!', k, ta1[k], ta2[k]); + return false; + } + } + return true; +} + +var xhr = new XMLHttpRequest(); +var url = "http://localhost:8888/binary"; +var urlUtf8 = "http://localhost:8888/binaryUtf8"; + +function download (xhr, url, responseType) +{ + if (responseType === undefined) + responseType = 'arraybuffer'; + return new Promise(function (resolve, reject) { + xhr.open("GET", url, true); + + xhr.responseType = responseType; + + xhr.onloadend = function () { + if (xhr.status >= 200 && xhr.status < 300) + { + switch (responseType) + { + case "": + case "text": + resolve(xhr.responseText); + break; + case "document": + resolve(xhr.responseXML); + break; + default: + resolve(xhr.response); + break; + } + } + else + { + var errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.send(); + }); +} + +/** + * Send a GET request to the server. + * When isUtf8 is true, assume that xhr.response is already + * utf8 encoded so that xhr.responseText. + * + * @param {string} url + * @param {boolean} isUtf8 + * @returns {Promise} + */ +function Get(url, isUtf8) { + return download(xhr, url, 'text').then((dataTxt) => { + return download(xhr, url, 'arraybuffer').then((ab) => { + var data = Buffer.from(ab); + + assert(dataTxt && data); + + log('XHR GET:', dataTxt.length, data.length, data.toString('utf8').length); + log('XHR GET:', data.constructor.name, dataTxt.constructor.name); + + if (isUtf8 && dataTxt.length !== data.toString('utf8').length) + throw new Error("xhr.responseText !== xhr.response.toString('utf8')"); + + var ta = isUtf8 ? new Float32Array(hexStr_to_ta(dataTxt)) : new Float32Array(data.buffer); + log('XHR GET:', ta.constructor.name, ta.length, ta[0], ta[1]); + + if (!checkEnough(ta, f32)) + throw new Error("Unable to correctly reconstitute Float32Array"); + + return ta; + }) + }); +} + +/** + * Test function which gets utf8 encoded bytes of the typed array + * new Uint8Array(new Float32Array(N).buffer), + * then it gets the raw bytes from + * new Uint8Array(new Float32Array(N).buffer). + * Before the utf8 tearing bug is fixed, + * Get(urlUtf8, true) + * will fail with the exception: + * Error: xhr.responseText !== xhr.response.toString('utf8'). + * + * @returns {Promise} + */ +function runTest() { + return Get(urlUtf8, true) + .then(function () { return Get(url, false); }); +} + +/** + * Run the test. + */ +setTimeout(function () { + runTest() + .then(function (ta) { + console.log("done", ta && ta.length); + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }) + .catch(function (e) { + console.log("FAILED"); + if (serverProcess) + serverProcess.close(); + serverProcess = null; + throw e; + }) +}, 100); + diff --git a/tests/testBinaryData b/tests/testBinaryData new file mode 100644 index 0000000..f998f0a Binary files /dev/null and b/tests/testBinaryData differ diff --git a/tests/testdata.txt b/tests/testdata.txt index 557db03..5e1c309 100644 --- a/tests/testdata.txt +++ b/tests/testdata.txt @@ -1 +1 @@ -Hello World +Hello World \ No newline at end of file