From df2aa1853d95ca8f6a9415ba0c2541469bbc6afc Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Wed, 27 May 2015 20:26:00 +0200 Subject: [PATCH 01/40] Tests are running now. --- README.md | 4 ++++ lib/XMLHttpRequest.js | 6 +++++- package.json | 11 +++++------ tests/test-exceptions.js | 5 +---- tests/test-request-protocols.js | 2 -- tests/testdata.txt | 2 +- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b989434..73f8876 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # node-XMLHttpRequest # +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). + +# Original README # + node-XMLHttpRequest is a wrapper for the built-in http client to emulate the browser XMLHttpRequest object. diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 52e36b3..bb52cce 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -200,19 +200,23 @@ 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"; + return false; } if (!isAllowedHttpHeader(header)) { console.warn('Refused to set unsafe header "' + header + '"'); - return; + return false; } if (sendFlag) { throw "INVALID_STATE_ERR: send flag is true"; + return false; } headers[header] = value; + return true; }; /** diff --git a/package.json b/package.json index 5cd17b8..bd45157 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": "1.0.0", "author": { - "name": "Dan DeFelippi", - "url": "http://driverdan.com" + "name": "Michael de Wit" }, "keywords": [ "xhr", @@ -18,9 +17,9 @@ ], "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" }, diff --git a/tests/test-exceptions.js b/tests/test-exceptions.js index f1edd71..10eaea2 100644 --- a/tests/test-exceptions.js +++ b/tests/test-exceptions.js @@ -44,15 +44,12 @@ var forbiddenRequestHeaders = [ "trailer", "transfer-encoding", "upgrade", - "user-agent", "via" ]; for (var i in forbiddenRequestHeaders) { - try { - xhr.setRequestHeader(forbiddenRequestHeaders[i], "Test"); + if(xhr.setRequestHeader(forbiddenRequestHeaders[i], "Test") !== false) { console.log("ERROR: " + forbiddenRequestHeaders[i] + " should have thrown exception"); - } catch(e) { } } diff --git a/tests/test-request-protocols.js b/tests/test-request-protocols.js index cd4e174..543917d 100644 --- a/tests/test-request-protocols.js +++ b/tests/test-request-protocols.js @@ -8,7 +8,6 @@ xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal("Hello World", this.responseText); - this.close(); runSync(); } }; @@ -25,7 +24,6 @@ var runSync = function() { xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal("Hello World", this.responseText); - this.close(); sys.puts("done"); } }; 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 From 494938e2dd4ae963cd5972f242e8eaf7d6042331 Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Wed, 27 May 2015 20:29:14 +0200 Subject: [PATCH 02/40] Switched to correct version. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bd45157..decfc43 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "1.0.0", + "version": "1.5.1", "author": { "name": "Michael de Wit" }, From 6e467d599d480587d7878267285d0fd91d2a800b Mon Sep 17 00:00:00 2001 From: Sami Jaktholm Date: Fri, 14 Aug 2015 10:11:44 +0300 Subject: [PATCH 03/40] Pass the TLS options to redirected requests correctly. --- lib/XMLHttpRequest.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index bb52cce..d64175f 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -436,13 +436,13 @@ function XMLHttpRequest(opts) { }; 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; + newOptions.pfx = opts.pfx; + newOptions.key = opts.key; + newOptions.passphrase = opts.passphrase; + newOptions.cert = opts.cert; + newOptions.ca = opts.ca; + newOptions.ciphers = opts.ciphers; + newOptions.rejectUnauthorized = opts.rejectUnauthorized; } // Issue the new request From f1f9298d1399a03dffa872f8829000e9b24dc904 Mon Sep 17 00:00:00 2001 From: Pavel Kucherov Date: Sun, 17 Jan 2016 02:44:29 +0300 Subject: [PATCH 04/40] Turn on strict mode --- lib/XMLHttpRequest.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index d64175f..8ff9415 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -35,6 +35,8 @@ XMLHttpRequest.XMLHttpRequest = XMLHttpRequest; */ function XMLHttpRequest(opts) { + "use strict"; + /** * Private variables */ @@ -414,7 +416,7 @@ function XMLHttpRequest(opts) { self.dispatchEvent("readystatechange"); // Handler for the response - function responseHandler(resp) { + var responseHandler = function(resp) { // Set response var to the response we got back // This is so it remains accessable outside this scope response = resp; @@ -482,7 +484,7 @@ function XMLHttpRequest(opts) { } // Error handler for the request - function errorHandler(error) { + var errorHandler = function(error) { self.handleError(error); } From 9eb7a1b04a356beb3dd47b2a9f10d92ee65ca8dd Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Tue, 26 Jan 2016 07:57:23 +0100 Subject: [PATCH 05/40] Added npm test script. Bumped version. --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index decfc43..de0218e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "1.5.1", + "version": "1.5.2", "author": { "name": "Michael de Wit" }, @@ -23,6 +23,9 @@ "engines": { "node": ">=0.4.0" }, + "scripts": { + "test": "cd ./tests && node test-constants.js && node test-events.js && node test-exceptions.js && node test-headers.js && node test-redirect-302.js && node test-redirect-303.js && node test-redirect-307.js && node test-request-methods.js && node test-request-protocols.js" + }, "directories": { "lib": "./lib", "example": "./example" From 83655a5263e2989f150881ec01e777b7be0c00ce Mon Sep 17 00:00:00 2001 From: Jussi Kinnula Date: Tue, 20 Sep 2016 14:14:24 +0300 Subject: [PATCH 06/40] Use response.setEncoding() only if the function exists in response --- lib/XMLHttpRequest.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 8ff9415..aad495d 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -454,7 +454,9 @@ function XMLHttpRequest(opts) { return; } - response.setEncoding("utf8"); + if (response && response.setEncoding) { + response.setEncoding("utf8"); + } setState(self.HEADERS_RECEIVED); self.status = response.statusCode; From d35e16183e162f3c67b29191ab20e4c81db68742 Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Tue, 20 Sep 2016 14:18:22 +0200 Subject: [PATCH 07/40] bumps version to 1.5.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index de0218e..8def16d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "1.5.2", + "version": "1.5.3", "author": { "name": "Michael de Wit" }, From ebc0076aa6169a3d229d888d70dc6818abcd89d5 Mon Sep 17 00:00:00 2001 From: david-clover-com Date: Mon, 25 Sep 2017 11:26:46 -0600 Subject: [PATCH 08/40] Fix critical issues --- README.md | 14 ++++++++------ lib/XMLHttpRequest.js | 8 +++++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 73f8876..e5be153 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,17 @@ 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). -# Original README # +## Usage ## -node-XMLHttpRequest is a wrapper for the built-in http client to emulate the -browser XMLHttpRequest object. +Here's how to include the module in your project and use as the browser-based +XHR object. -This can be used with JS designed for browsers to improve reuse of code and -allow the use of existing libraries. + var XMLHttpRequest = require("xmlhttprequest-ssl").XMLHttpRequest; + var xhr = new XMLHttpRequest(); -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/). +Note: use the lowercase string "xmlhttprequest-ssl" in your require(). On +case-sensitive systems (eg Linux) using uppercase letters won't work. +# Original README # ## Usage ## diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index aad495d..b9e069e 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -61,7 +61,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: @@ -474,9 +474,11 @@ function XMLHttpRequest(opts) { 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; // Discard the 'end' event if the connection has been aborted setState(self.DONE); - sendFlag = false; } }); @@ -574,7 +576,7 @@ function XMLHttpRequest(opts) { request = null; } - headers = defaultHeaders; + headers = Object.assign({}, defaultHeaders); this.responseText = ""; this.responseXML = ""; From b0fa1e0ddb900e28cf6cfbf8f6ade2babc4a12f9 Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Tue, 26 Sep 2017 16:51:22 +0200 Subject: [PATCH 09/40] Prepare release 1.5.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8def16d..cce19f9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "1.5.3", + "version": "1.5.4", "author": { "name": "Michael de Wit" }, From cefa6fa6ad698ed05b30c47794eceb5cfea97ca8 Mon Sep 17 00:00:00 2001 From: david-clover-com Date: Fri, 5 Jan 2018 11:12:48 -0700 Subject: [PATCH 10/40] ops can be null, there are places in the code that check for a null opts, and other places where a check is not made --- lib/XMLHttpRequest.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index aad495d..0591158 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -37,6 +37,8 @@ XMLHttpRequest.XMLHttpRequest = XMLHttpRequest; function XMLHttpRequest(opts) { "use strict"; + opts = opts || {}; + /** * Private variables */ @@ -378,10 +380,7 @@ function XMLHttpRequest(opts) { headers["Content-Length"] = 0; } - var agent = false; - if (opts && opts.agent) { - agent = opts.agent; - } + var agent = opts.agent || false; var options = { host: host, port: port, From d0290fab4b9674f007408d2d420096f343818f25 Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Thu, 11 Jan 2018 07:41:50 +0100 Subject: [PATCH 11/40] Prepare release 1.5.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cce19f9..1738267 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "1.5.4", + "version": "1.5.5", "author": { "name": "Michael de Wit" }, From 9d32e4fe704677201ec36aeb63f98140cfe323eb Mon Sep 17 00:00:00 2001 From: Wes Garland Date: Wed, 17 Jul 2019 11:34:10 -0400 Subject: [PATCH 12/40] Fixed abort/error/loadend event firing; statusCode is now 0 when all HTTP errors fire; now always throw new Error() instead of string --- lib/XMLHttpRequest.js | 72 ++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 3223020..50f6158 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -102,6 +102,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 = {}; @@ -172,10 +173,11 @@ 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 = { @@ -208,16 +210,14 @@ function XMLHttpRequest(opts) { */ this.setRequestHeader = function(header, value) { if (this.readyState != this.OPENED) { - throw "INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN"; - return false; + 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 false; } if (sendFlag) { - throw "INVALID_STATE_ERR: send flag is true"; - return false; + throw new Error("INVALID_STATE_ERR: send flag is true"); } headers[header] = value; return true; @@ -283,11 +283,11 @@ 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; @@ -312,13 +312,13 @@ function XMLHttpRequest(opts) { break; default: - throw "Protocol not supported."; + throw new Error("Protocol not supported."); } // Load files off the local filesystem (file://) if (local) { if (settings.method !== "GET") { - throw "XMLHttpRequest: Only GET method is supported"; + throw new Error("XMLHttpRequest: Only GET method is supported"); } if (settings.async) { @@ -402,7 +402,6 @@ function XMLHttpRequest(opts) { // Reset error flag errorFlag = false; - // Handle async requests if (settings.async) { // Use the proper protocol @@ -545,7 +544,7 @@ function XMLHttpRequest(opts) { if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) { // If the file returned an error, handle it var errorObj = self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, ""); - self.handleError(errorObj); + 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"); @@ -557,9 +556,10 @@ 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.handleError = function(error, status) { + this.status = +status || 0; this.statusText = error; this.responseText = error.stack; errorFlag = true; @@ -579,8 +579,7 @@ function XMLHttpRequest(opts) { this.responseText = ""; this.responseXML = ""; - errorFlag = true; - + errorFlag = abortedFlag = true if (this.readyState !== this.UNSENT && (this.readyState !== this.OPENED || sendFlag) && this.readyState !== this.DONE) { @@ -619,11 +618,17 @@ function XMLHttpRequest(opts) { */ this.dispatchEvent = function(event) { if (typeof self["on" + event] === "function") { - self["on" + event](); + if (this.readyState === this.DONE) + setImmediate(function() { self["on" + event]() }) + else + self["on" + event]() } 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) + setImmediate(function() { listeners[event][i].call(self) }) + else + listeners[event][i].call(self) } } }; @@ -634,18 +639,29 @@ function XMLHttpRequest(opts) { * @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"); } }; }; From b9fedb035e7a3850d31391a6b6cdcf6d0f340d15 Mon Sep 17 00:00:00 2001 From: Wes Garland Date: Wed, 17 Jul 2019 12:09:46 -0400 Subject: [PATCH 13/40] pushed version to 1.5.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1738267..55ab8fd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "1.5.5", + "version": "1.5.6", "author": { "name": "Michael de Wit" }, From a9d93fb6895952bd0ddab09bb294a45502b2dcf5 Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Wed, 31 Jul 2019 19:24:26 +0200 Subject: [PATCH 14/40] Replace deprecated sys.puts calls with console.log in tests --- example/demo.js | 8 ++++---- tests/test-constants.js | 2 +- tests/test-events.js | 2 +- tests/test-headers.js | 2 +- tests/test-redirect-302.js | 2 +- tests/test-redirect-303.js | 2 +- tests/test-redirect-307.js | 2 +- tests/test-request-methods.js | 6 +++--- tests/test-request-protocols.js | 2 +- 9 files changed, 14 insertions(+), 14 deletions(-) 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/tests/test-constants.js b/tests/test-constants.js index 372e46c..4c697e2 100644 --- a/tests/test-constants.js +++ b/tests/test-constants.js @@ -10,4 +10,4 @@ assert.equal(2, xhr.HEADERS_RECEIVED); assert.equal(3, xhr.LOADING); assert.equal(4, xhr.DONE); -sys.puts("done"); +console.log("done"); diff --git a/tests/test-events.js b/tests/test-events.js index c72f001..3296f8b 100644 --- a/tests/test-events.js +++ b/tests/test-events.js @@ -20,7 +20,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-headers.js b/tests/test-headers.js index 76454f1..27ecc25 100644 --- a/tests/test-headers.js +++ b/tests/test-headers.js @@ -47,7 +47,7 @@ xhr.onreadystatechange = function() { assert.equal("", this.getAllResponseHeaders()); assert.equal(null, this.getResponseHeader("Connection")); - sys.puts("done"); + console.log("done"); } }; diff --git a/tests/test-redirect-302.js b/tests/test-redirect-302.js index d884f78..0b87192 100644 --- a/tests/test-redirect-302.js +++ b/tests/test-redirect-302.js @@ -29,7 +29,7 @@ xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); - sys.puts("done"); + console.log("done"); } }; diff --git a/tests/test-redirect-303.js b/tests/test-redirect-303.js index 60d9343..aa85b2d 100644 --- a/tests/test-redirect-303.js +++ b/tests/test-redirect-303.js @@ -29,7 +29,7 @@ xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); - sys.puts("done"); + console.log("done"); } }; diff --git a/tests/test-redirect-307.js b/tests/test-redirect-307.js index 3abc906..a73819f 100644 --- a/tests/test-redirect-307.js +++ b/tests/test-redirect-307.js @@ -31,7 +31,7 @@ xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); - sys.puts("done"); + console.log("done"); } }; diff --git a/tests/test-request-methods.js b/tests/test-request-methods.js index fa1b1be..f8d66d8 100644 --- a/tests/test-request-methods.js +++ b/tests/test-request-methods.js @@ -24,7 +24,7 @@ var server = http.createServer(function (req, res) { if (curMethod == methods.length - 1) { this.close(); - sys.puts("done"); + console.log("done"); } }).listen(8000); @@ -47,7 +47,7 @@ function start(method) { curMethod++; if (curMethod < methods.length) { - sys.puts("Testing " + methods[curMethod]); + console.log("Testing " + methods[curMethod]); start(methods[curMethod]); } } @@ -58,5 +58,5 @@ function start(method) { xhr.send(); } -sys.puts("Testing " + methods[curMethod]); +console.log("Testing " + methods[curMethod]); start(methods[curMethod]); diff --git a/tests/test-request-protocols.js b/tests/test-request-protocols.js index 543917d..f745cb8 100644 --- a/tests/test-request-protocols.js +++ b/tests/test-request-protocols.js @@ -24,7 +24,7 @@ var runSync = function() { xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal("Hello World", this.responseText); - sys.puts("done"); + console.log("done"); } }; xhr.open("GET", url, false); From 534b58606d4eec01d626be5b0617db40e72fc6cf Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Wed, 31 Jul 2019 19:27:05 +0200 Subject: [PATCH 15/40] Remove superfluous + operator --- lib/XMLHttpRequest.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 50f6158..2814f5d 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -559,7 +559,7 @@ function XMLHttpRequest(opts) { * @param status {number} HTTP status code to use rather than the default (0) for XHR errors. */ this.handleError = function(error, status) { - this.status = +status || 0; + this.status = status || 0; this.statusText = error; this.responseText = error.stack; errorFlag = true; From ae38832a0f1347c5e96dda665402509a3458e302 Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Wed, 31 Jul 2019 19:28:30 +0200 Subject: [PATCH 16/40] 1.6.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 55ab8fd..a6c8bc4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "1.5.6", + "version": "1.6.0", "author": { "name": "Michael de Wit" }, From bf53329b61ca6afc5d28f6b8d2dc2e3ca740a9b2 Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Mon, 19 Apr 2021 12:21:05 +0200 Subject: [PATCH 17/40] Fix issue where rejectUnauthorized would default to false instead of true --- lib/XMLHttpRequest.js | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 2814f5d..3c23907 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -397,7 +397,7 @@ function XMLHttpRequest(opts) { options.cert = opts.cert; options.ca = opts.ca; options.ciphers = opts.ciphers; - options.rejectUnauthorized = opts.rejectUnauthorized; + options.rejectUnauthorized = opts.rejectUnauthorized === false ? false : true; } // Reset error flag @@ -442,7 +442,7 @@ function XMLHttpRequest(opts) { newOptions.cert = opts.cert; newOptions.ca = opts.ca; newOptions.ciphers = opts.ciphers; - newOptions.rejectUnauthorized = opts.rejectUnauthorized; + newOptions.rejectUnauthorized = opts.rejectUnauthorized === false ? false : true; } // Issue the new request diff --git a/package.json b/package.json index a6c8bc4..235564a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "1.6.0", + "version": "1.6.1", "author": { "name": "Michael de Wit" }, From 6a0a91d832419430adf13d64c4b70690228634d7 Mon Sep 17 00:00:00 2001 From: Martin Carlberg Date: Sat, 2 Apr 2016 12:23:06 +0200 Subject: [PATCH 18/40] Unescape pathname from url when loading from local filesystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a path contains a space it will be escaped to ’%20’. When loading from local filesystem it has to be converted back to a space to get the correct path. --- lib/XMLHttpRequest.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 3c23907..80b58c1 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -322,7 +322,7 @@ function XMLHttpRequest(opts) { } if (settings.async) { - fs.readFile(url.pathname, 'utf8', function(error, data) { + fs.readFile(unescape(url.pathname), 'utf8', function(error, data) { if (error) { self.handleError(error); } else { @@ -333,7 +333,7 @@ function XMLHttpRequest(opts) { }); } else { try { - this.responseText = fs.readFileSync(url.pathname, 'utf8'); + this.responseText = fs.readFileSync(unescape(url.pathname), 'utf8'); this.status = 200; setState(self.DONE); } catch(e) { From ee1e81fc67729c7c0eba5537ed7fe1e30a6b3291 Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Tue, 4 May 2021 12:03:02 +0200 Subject: [PATCH 19/40] Fix CVE-2020-28502 --- lib/XMLHttpRequest.js | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 3c23907..447ec20 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -528,7 +528,7 @@ function XMLHttpRequest(opts) { + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + "fs.unlinkSync('" + syncFile + "');" + "});" - + (data ? "req.write('" + data.replace(/'/g, "\\'") + "');":"") + + (data ? "req.write('" + JSON.stringify(data).slice(1,-1).replace(/'/g, "\\'") + "');":"") + "req.end();"; // Start the other Node Process, executing this string var syncProc = spawn(process.argv[0], ["-e", execString]); diff --git a/package.json b/package.json index 235564a..eeea2f3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "1.6.1", + "version": "1.6.2", "author": { "name": "Michael de Wit" }, From 711bd4aa0cd31104f76a10fa67bdb69e6954f1aa Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Mon, 17 May 2021 12:03:16 +0200 Subject: [PATCH 20/40] Prepare release 1.6.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eeea2f3..fc875d8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "1.6.2", + "version": "1.6.3", "author": { "name": "Michael de Wit" }, From fd05315b41a07b77bfd0fc032a2e74159edd2c1b Mon Sep 17 00:00:00 2001 From: Philip Shao Date: Fri, 21 May 2021 16:28:08 -0400 Subject: [PATCH 21/40] Add support for autoUnref. From https://github.com/socketio/engine.io-client/commit/65516836b2b6fe28d80e9a5918f9e10baa7451d8 --- lib/XMLHttpRequest.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 59dcaf1..659a149 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -493,6 +493,12 @@ function XMLHttpRequest(opts) { // Create the request request = doRequest(options, responseHandler).on('error', errorHandler); + if (opts.autoUnref) { + request.on('socket', (socket) => { + socket.unref(); + }); + } + // Node 0.4 and later won't accept empty data. Make sure it's needed. if (data) { request.write(data); From fbe7c079df24d4d12118f399226076bd8eb97af9 Mon Sep 17 00:00:00 2001 From: Martin Carlberg Date: Thu, 27 May 2021 11:29:31 +0200 Subject: [PATCH 22/40] Expose error in status property when loading from local file system --- lib/XMLHttpRequest.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 59dcaf1..483aea9 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -324,7 +324,7 @@ function XMLHttpRequest(opts) { if (settings.async) { fs.readFile(unescape(url.pathname), 'utf8', function(error, data) { if (error) { - self.handleError(error); + self.handleError(error, error.errno || -1); } else { self.status = 200; self.responseText = data; @@ -337,7 +337,7 @@ function XMLHttpRequest(opts) { this.status = 200; setState(self.DONE); } catch(e) { - this.handleError(e); + this.handleError(e, e.errno || -1); } } From cbd48f1f63c2842d502d19323bb988c3ca8214da Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Thu, 17 Jun 2021 12:07:15 +0200 Subject: [PATCH 23/40] Prepare release 2.0.0 --- README.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e5be153..7e09b94 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ case-sensitive systems (eg Linux) using uppercase letters won't work. ## 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/package.json b/package.json index fc875d8..ba87b85 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "1.6.3", + "version": "2.0.0", "author": { "name": "Michael de Wit" }, From 8514b4e42a910b1821a0523c088bff2fd6f66f5e Mon Sep 17 00:00:00 2001 From: RobertMirandola <72895655+RobertMirandola@users.noreply.github.com> Date: Wed, 12 Oct 2022 03:47:43 -0400 Subject: [PATCH 24/40] fix: proper fetching of binary data during get requests (#11) * fix: proper fetching of binary data during get requests * Add this.response for local files and account for this.response within this.abort() * Remove binary encoding option to get raw buffer for data and change reading from local files to only happen once in order to attain response and responseText * Add test file for binary data and rename initial request protocol test to specify that it is testing text data * Clean up logBinary function in binary data test file * Add renamed test file and new test file to test script in package.json --- lib/XMLHttpRequest.js | 28 ++++++---- package.json | 2 +- tests/test-request-protocols-binary-data.js | 48 ++++++++++++++++++ ....js => test-request-protocols-txt-data.js} | 0 tests/testBinaryData | Bin 0 -> 16 bytes 5 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 tests/test-request-protocols-binary-data.js rename tests/{test-request-protocols.js => test-request-protocols-txt-data.js} (100%) create mode 100644 tests/testBinaryData diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index c0da822..8b01e82 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -130,6 +130,7 @@ function XMLHttpRequest(opts) { // Result & response this.responseText = ""; this.responseXML = ""; + this.response = Buffer.alloc(0); this.status = null; this.statusText = null; @@ -322,18 +323,20 @@ function XMLHttpRequest(opts) { } if (settings.async) { - fs.readFile(unescape(url.pathname), 'utf8', function(error, data) { + fs.readFile(unescape(url.pathname), function(error, data) { if (error) { self.handleError(error, error.errno || -1); } else { self.status = 200; - self.responseText = data; + self.responseText = data.toString('utf8'); + self.response = data; setState(self.DONE); } }); } else { try { - this.responseText = fs.readFileSync(unescape(url.pathname), 'utf8'); + this.response = fs.readFileSync(unescape(url.pathname)); + this.responseText = this.response.toString('utf8'); this.status = 200; setState(self.DONE); } catch(e) { @@ -452,17 +455,15 @@ function XMLHttpRequest(opts) { return; } - if (response && response.setEncoding) { - response.setEncoding("utf8"); - } - setState(self.HEADERS_RECEIVED); self.status = response.statusCode; response.on('data', function(chunk) { // Make sure there's some data if (chunk) { - self.responseText += chunk; + var data = Buffer.from(chunk); + self.responseText += data.toString('utf8'); + self.response = Buffer.concat([self.response, data]); } // Don't emit state changes if the connection has been aborted. if (sendFlag) { @@ -517,13 +518,15 @@ function XMLHttpRequest(opts) { + "var doRequest = http" + (ssl ? "s" : "") + ".request;" + "var options = " + JSON.stringify(options) + ";" + "var responseText = '';" + + "var responseData = Buffer.alloc(0);" + "var req = doRequest(options, function(response) {" - + "response.setEncoding('utf8');" + "response.on('data', function(chunk) {" - + " responseText += chunk;" + + " var data = Buffer.from(chunk);" + + " responseText += data.toString('utf8');" + + " responseData = Buffer.concat([responseData, data]);" + "});" + "response.on('end', function() {" - + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8');" + + "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText, data: responseData.toString('base64')}}), 'utf8');" + "fs.unlinkSync('" + syncFile + "');" + "});" + "response.on('error', function(error) {" @@ -555,6 +558,8 @@ function XMLHttpRequest(opts) { // 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"); + var resp = JSON.parse(self.responseText); + self.response = Buffer.from(resp.data.data, 'base64'); setState(self.DONE); } } @@ -584,6 +589,7 @@ function XMLHttpRequest(opts) { headers = Object.assign({}, defaultHeaders); this.responseText = ""; this.responseXML = ""; + this.response = Buffer.alloc(0); errorFlag = abortedFlag = true if (this.readyState !== this.UNSENT diff --git a/package.json b/package.json index ba87b85..cfd232d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "node": ">=0.4.0" }, "scripts": { - "test": "cd ./tests && node test-constants.js && node test-events.js && node test-exceptions.js && node test-headers.js && node test-redirect-302.js && node test-redirect-303.js && node test-redirect-307.js && node test-request-methods.js && node test-request-protocols.js" + "test": "cd ./tests && node test-constants.js && node test-events.js && node test-exceptions.js && node test-headers.js && node test-redirect-302.js && node test-redirect-303.js && node test-redirect-307.js && node test-request-methods.js && node test-request-protocols-txt-data.js && node test-request-protocols-binary-data.js" }, "directories": { "lib": "./lib", diff --git a/tests/test-request-protocols-binary-data.js b/tests/test-request-protocols-binary-data.js new file mode 100644 index 0000000..4a3e42c --- /dev/null +++ b/tests/test-request-protocols-binary-data.js @@ -0,0 +1,48 @@ +var sys = require("util") + , assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , xhr; + +xhr = new XMLHttpRequest(); + +xhr.onreadystatechange = function() { + if (this.readyState == 4) { + assert.equal("0000 803f 0000 a040 0000 c040 0000 e040", logBinary(this.response.toString('binary'))); + runSync(); + } +}; + +// Async +var url = "file://" + __dirname + "/testBinaryData"; +xhr.open("GET", url); +xhr.send(); + +// Sync +var runSync = function() { + xhr = new XMLHttpRequest(); + + xhr.onreadystatechange = function() { + if (this.readyState == 4) { + assert.equal("0000 803f 0000 a040 0000 c040 0000 e040", logBinary(this.response.toString('binary'))); + console.log("done"); + } + }; + xhr.open("GET", url, false); + xhr.send(); +} + +function logBinary(data) { + function log(data, idx) { + return data.charCodeAt(idx).toString(16).padStart(2, '0'); + } + 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 100% rename from tests/test-request-protocols.js rename to tests/test-request-protocols-txt-data.js diff --git a/tests/testBinaryData b/tests/testBinaryData new file mode 100644 index 0000000000000000000000000000000000000000..f998f0af87722b080b5ce8509cb4225f8bef6bc8 GIT binary patch literal 16 WcmZQzXs~BsSm40GZ~%xOH~;_|g9E?- literal 0 HcmV?d00001 From ab9042139f67303046ce47e8ed336de0f4cd2617 Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Wed, 12 Oct 2022 10:09:59 +0200 Subject: [PATCH 25/40] Fix sync response handling of remote requests Add test for sync handling of remote requests Add headers to sync responses (remote requests) Prepare release 2.1.0 --- lib/XMLHttpRequest.js | 16 ++++++----- package.json | 4 +-- tests/server.js | 30 +++++++++++++++++++++ tests/test-sync-response.js | 53 +++++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 tests/server.js create mode 100644 tests/test-sync-response.js diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 8b01e82..ef447e3 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -552,15 +552,19 @@ function XMLHttpRequest(opts) { fs.unlinkSync(contentFile); if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) { // If the file returned an error, handle it - var errorObj = self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, ""); + 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"); - var resp = JSON.parse(self.responseText); + var resp = JSON.parse(self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, "$1")); + response = { + statusCode: self.status, + headers: resp.data.headers + }; + self.responseText = resp.data.text; self.response = Buffer.from(resp.data.data, 'base64'); - setState(self.DONE); + setState(self.DONE, true); } } }; @@ -628,9 +632,9 @@ function XMLHttpRequest(opts) { /** * Dispatch any events, including both "on" methods and events attached using addEventListener. */ - this.dispatchEvent = function(event) { + this.dispatchEvent = function (event) { if (typeof self["on" + event] === "function") { - if (this.readyState === this.DONE) + if (this.readyState === this.DONE && settings.async) setImmediate(function() { self["on" + event]() }) else self["on" + event]() diff --git a/package.json b/package.json index cfd232d..9769524 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "2.0.0", + "version": "2.1.0", "author": { "name": "Michael de Wit" }, @@ -24,7 +24,7 @@ "node": ">=0.4.0" }, "scripts": { - "test": "cd ./tests && node test-constants.js && node test-events.js && node test-exceptions.js && node test-headers.js && node test-redirect-302.js && node test-redirect-303.js && node test-redirect-307.js && node test-request-methods.js && node test-request-protocols-txt-data.js && node test-request-protocols-binary-data.js" + "test": "cd ./tests && node test-constants.js && node test-events.js && node test-exceptions.js && node test-headers.js && node test-redirect-302.js && node test-redirect-303.js && node test-redirect-307.js && node test-request-methods.js && node test-request-protocols-txt-data.js && node test-request-protocols-binary-data.js && node test-sync-response.js" }, "directories": { "lib": "./lib", diff --git a/tests/server.js b/tests/server.js new file mode 100644 index 0000000..74ccf83 --- /dev/null +++ b/tests/server.js @@ -0,0 +1,30 @@ +var http = require("http"); + +var server = http.createServer(function (req, res) { + switch (req.url) { + case "/text": + return res + .writeHead(200, {"Content-Type": "text/plain"}) + .end("Hello world!"); + case "/xml": + return res + .writeHead(200, {"Content-Type": "application/xml"}) + .end("Foobar"); + case "/json": + return res + .writeHead(200, {"Content-Type": "application/json"}) + .end(JSON.stringify({ foo: "bar" })); + case "/binary": + return res + .writeHead(200, {"Content-Type": "application/octet-stream"}) + .end(Buffer.from("Hello world!")); + default: + return res + .writeHead(404, {"Content-Type": "text/plain"}) + .end("Not found"); + } +}).listen(8888); + +process.on("SIGINT", function () { + server.close(); +}); diff --git a/tests/test-sync-response.js b/tests/test-sync-response.js new file mode 100644 index 0000000..4809076 --- /dev/null +++ b/tests/test-sync-response.js @@ -0,0 +1,53 @@ +var assert = require("assert") + , spawn = require('child_process').spawn + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , serverProcess; + +// 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() + } catch (e) { + throw e + } finally { + serverProcess.kill('SIGINT'); + } +}, 100); + +function runTest() { + var xhr = new XMLHttpRequest(); + var isSync = false; + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + 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) { + assert.equal(xhr.response.toString(), 'Hello world!'); + assert.equal(xhr.getResponseHeader('content-type'), 'application/octet-stream') + isSync = true; + } + } + + xhr.open("GET", "http://localhost:8888/binary", false); + xhr.send(); + + assert(isSync, "XMLHttpRequest was not synchronous"); + + console.log("done"); +} \ No newline at end of file From cf5742947ce9eb306dbde9fd7a8650ec10cdd062 Mon Sep 17 00:00:00 2001 From: Eddie Roosenmaallen Date: Sun, 16 Jul 2023 08:24:36 -0400 Subject: [PATCH 26/40] Prevent corruption of UTF-8 multibyte codepoints at fragment boundary (#13) * To prevent `chunked` encoding breaking surrogate pairs, populate self.responseText only once, after self.response has been fully reassembled. Co-authored-by: Paul Ringseth * Added test to demonstrate UTF-8 tearing Co-Authored-By: Paul Ringseth * Added test-utf8-tearing to test script --- lib/XMLHttpRequest.js | 3 +- package.json | 2 +- tests/test-utf8-tearing.js | 243 +++++++++++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 tests/test-utf8-tearing.js diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index ef447e3..0f6918c 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -462,7 +462,6 @@ function XMLHttpRequest(opts) { // Make sure there's some data if (chunk) { var data = Buffer.from(chunk); - self.responseText += data.toString('utf8'); self.response = Buffer.concat([self.response, data]); } // Don't emit state changes if the connection has been aborted. @@ -478,6 +477,8 @@ function XMLHttpRequest(opts) { sendFlag = false; // Discard the 'end' event if the connection has been aborted setState(self.DONE); + // Construct responseText from response + self.responseText = self.response.toString('utf8'); } }); diff --git a/package.json b/package.json index 9769524..3e6d624 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "node": ">=0.4.0" }, "scripts": { - "test": "cd ./tests && node test-constants.js && node test-events.js && node test-exceptions.js && node test-headers.js && node test-redirect-302.js && node test-redirect-303.js && node test-redirect-307.js && node test-request-methods.js && node test-request-protocols-txt-data.js && node test-request-protocols-binary-data.js && node test-sync-response.js" + "test": "cd ./tests && node test-constants.js && node test-events.js && node test-exceptions.js && node test-headers.js && node test-redirect-302.js && node test-redirect-303.js && node test-redirect-307.js && node test-request-methods.js && node test-request-protocols-txt-data.js && node test-request-protocols-binary-data.js && node test-sync-response.js && node test-utf8-tearing.js" }, "directories": { "lib": "./lib", diff --git a/tests/test-utf8-tearing.js b/tests/test-utf8-tearing.js new file mode 100644 index 0000000..cd3d849 --- /dev/null +++ b/tests/test-utf8-tearing.js @@ -0,0 +1,243 @@ + +/****************************************************************************************** + * 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'); + */ +// @ts-check +'use strict'; + +const assert = require("assert"); +const http = require("http"); + +const useLocalXHR = true; +const XHRModule = useLocalXHR ? "../lib/XMLHttpRequest" : "xmlhttprequest-ssl"; +const { XMLHttpRequest } = require(XHRModule); + +const supressConsoleOutput = true; +function log (...args) { + if ( !supressConsoleOutput) + console.debug(...args); +} + +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) { + const u8 = new Uint8Array(ta.buffer); + return u8.reduce((acc, cur) => acc + String.fromCharCode(cur), ""); +} + +/** + * Create a random Float32Array of length N. + * + * @param {number} N + * @returns {Float32Array} + */ +function createFloat32Array(N) { + assert(N > 0); + let ta = new Float32Array(N); + for (let k = 0; k < ta.length; k++) + ta[k] = Math.random(); + //ta = new Float32Array([1, 5, 6, 7]); // Use to debug + return ta; +} +const N = 1 * 1000 * 1000; // Needs to be big enough to tear a few utf8 sequences. +const 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) { + const buffer = Buffer.from(f32.buffer); + const ss = ta_to_hexStr(f32); + const bufferUtf8 = Buffer.from(ss, 'utf8'); // Encode ss in utf8 + return { buffer, bufferUtf8 }; +} +const { buffer, bufferUtf8 } = createBuffers(f32); + +/** + * 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": + return res + .writeHead(200, {"Content-Type": "application/octet-stream"}) + .end(buffer); + case "/binaryUtf8": + return res + .writeHead(200, {"Content-Type": "application/octet-stream"}) + .end(bufferUtf8); + default: + return res + .writeHead(404, {"Content-Type": "text/plain"}) + .end("Not found"); + } + }).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) { + 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); +} + +/** + * 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 = 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 (let 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; +} + +const xhr = new XMLHttpRequest(); +const url = "http://localhost:8888/binary"; +const urlUtf8 = "http://localhost:8888/binaryUtf8"; + +/** + * 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 new Promise((resolve, reject) => { + xhr.open("GET", url, true); + xhr.onloadend = function(event) { + + log('xhr.status:', xhr.status); + + if (xhr.status >= 200 && xhr.status < 300) { + const contentType = xhr.getResponseHeader('content-type'); + assert.equal(contentType, 'application/octet-stream'); + + const dataTxt = xhr.responseText; + const data = xhr.response; + assert(dataTxt && data); + + log('XHR GET:', contentType, 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')"); + + const 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"); + + resolve(ta); + } + reject(new Error(`Request failed: xhr.status ${xhr.status}`)); + } + xhr.send(); + }); +} + +/** + * 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(() => { return Get(url, false); }); +} + +/** + * Run the test. + */ +setTimeout(function () { + runTest() + .then((ta) => { console.log("done", ta?.length); }) + .finally(() => { + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }); +}, 100); + From b01f69a6899d56fe9830618b4d2463742a57a900 Mon Sep 17 00:00:00 2001 From: Eric Wooley <1930227+ericwooley@users.noreply.github.com> Date: Sun, 16 Jul 2023 05:28:19 -0700 Subject: [PATCH 27/40] Replace setImmediate with setTimeout 0 (#14) --- lib/XMLHttpRequest.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 0f6918c..e21eea8 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -636,14 +636,14 @@ function XMLHttpRequest(opts) { this.dispatchEvent = function (event) { if (typeof self["on" + event] === "function") { if (this.readyState === this.DONE && settings.async) - setImmediate(function() { self["on" + event]() }) + setTimeout(function() { self["on" + event]() }, 0) else self["on" + event]() } if (event in listeners) { for (let i = 0, len = listeners[event].length; i < len; i++) { if (this.readyState === this.DONE) - setImmediate(function() { listeners[event][i].call(self) }) + setTimeout(function() { listeners[event][i].call(self) }, 0) else listeners[event][i].call(self) } From 39efe36ba20acbd463f550fb4d9e9862c98ab012 Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Sun, 16 Jul 2023 14:29:36 +0200 Subject: [PATCH 28/40] Prepare release 2.1.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3e6d624..01321d9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "2.1.0", + "version": "2.1.1", "author": { "name": "Michael de Wit" }, From b0271d5e52692d9f48da6088b27d5bf2a6f50d86 Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Mon, 17 Jul 2023 21:28:58 +0200 Subject: [PATCH 29/40] Add GitHub action workflow config containing integration-tests (#15) --- .github/workflows/test.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d12e24e --- /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: [14.x, 16.x, 18.x, 20.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 From 365162091ab23806f70843f7bc25c194a789216d Mon Sep 17 00:00:00 2001 From: Ryan <44614514+YarnSaw@users.noreply.github.com> Date: Wed, 23 Oct 2024 14:15:42 -0400 Subject: [PATCH 30/40] Case-insensitive check for content type before setting it (#18) * Case-insensitive check for content type before setting it * Update lib/XMLHttpRequest.js - compatibility & more accurate match to content-type Co-authored-by: Michael de Wit --------- Co-authored-by: Michael de Wit --- lib/XMLHttpRequest.js | 3 ++- tests/test-headers.js | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index e21eea8..e6e2a0f 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -374,7 +374,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") { diff --git a/tests/test-headers.js b/tests/test-headers.js index 27ecc25..90a0576 100644 --- a/tests/test-headers.js +++ b/tests/test-headers.js @@ -12,6 +12,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, { @@ -53,13 +55,16 @@ xhr.onreadystatechange = function() { 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 +75,7 @@ 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); } From 7be2aa6e681c309870bf836965e2ec1fa014b480 Mon Sep 17 00:00:00 2001 From: Ryan <44614514+YarnSaw@users.noreply.github.com> Date: Wed, 23 Oct 2024 15:06:06 -0400 Subject: [PATCH 31/40] Handle ECONNRESET possible on reused sockets (#19) * Handle ECONNRESET possible on reused sockets If a request reuses a socket (due to an agent with keepalive), it's possible for the client to error with ECONNRESET. This error has nothing to do with the message being invalid, the server not responding, etc, but rather just very misfortunate timings. In this case, retry the request instead of erroring the xhr request * Update test for compatibility * Update test-utf8-tearing for early-node compatibility --- lib/XMLHttpRequest.js | 4 +++ package.json | 2 +- tests/test-keepalive.js | 33 ++++++++++++++++++++++++ tests/test-utf8-tearing.js | 51 ++++++++++++++++++-------------------- 4 files changed, 62 insertions(+), 28 deletions(-) create mode 100644 tests/test-keepalive.js diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index e6e2a0f..8ede69a 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -490,6 +490,10 @@ function XMLHttpRequest(opts) { // Error handler for the request 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); } diff --git a/package.json b/package.json index 01321d9..369f688 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "node": ">=0.4.0" }, "scripts": { - "test": "cd ./tests && node test-constants.js && node test-events.js && node test-exceptions.js && node test-headers.js && node test-redirect-302.js && node test-redirect-303.js && node test-redirect-307.js && node test-request-methods.js && node test-request-protocols-txt-data.js && node test-request-protocols-binary-data.js && node test-sync-response.js && node test-utf8-tearing.js" + "test": "cd ./tests && node test-constants.js && node test-events.js && node test-exceptions.js && node test-headers.js && node test-redirect-302.js && node test-redirect-303.js && node test-redirect-307.js && node test-request-methods.js && node test-request-protocols-txt-data.js && node test-request-protocols-binary-data.js && node test-sync-response.js && node test-utf8-tearing.js && node test-keepalive.js" }, "directories": { "lib": "./lib", diff --git a/tests/test-keepalive.js b/tests/test-keepalive.js new file mode 100644 index 0000000..0e5aac1 --- /dev/null +++ b/tests/test-keepalive.js @@ -0,0 +1,33 @@ +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; +const 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); + server.close(); + } + if (repeats++ > maxMessages) { + console.log('Done.'); + clearInterval(interval); + server.close(); + } + } + xhr.send(); +}, 200); \ No newline at end of file diff --git a/tests/test-utf8-tearing.js b/tests/test-utf8-tearing.js index cd3d849..65e9319 100644 --- a/tests/test-utf8-tearing.js +++ b/tests/test-utf8-tearing.js @@ -16,14 +16,11 @@ // @ts-check 'use strict'; -const assert = require("assert"); -const http = require("http"); +var assert = require("assert"); +var http = require("http"); +var { XMLHttpRequest } = require("../lib/XMLHttpRequest"); -const useLocalXHR = true; -const XHRModule = useLocalXHR ? "../lib/XMLHttpRequest" : "xmlhttprequest-ssl"; -const { XMLHttpRequest } = require(XHRModule); - -const supressConsoleOutput = true; +var supressConsoleOutput = true; function log (...args) { if ( !supressConsoleOutput) console.debug(...args); @@ -47,8 +44,8 @@ var serverProcess; * @returns {string} */ function ta_to_hexStr(ta) { - const u8 = new Uint8Array(ta.buffer); - return u8.reduce((acc, cur) => acc + String.fromCharCode(cur), ""); + var u8 = new Uint8Array(ta.buffer); + return u8.reduce(function (acc, cur) { return acc + String.fromCharCode(cur), ""}); } /** @@ -65,8 +62,8 @@ function createFloat32Array(N) { //ta = new Float32Array([1, 5, 6, 7]); // Use to debug return ta; } -const N = 1 * 1000 * 1000; // Needs to be big enough to tear a few utf8 sequences. -const f32 = createFloat32Array(N); +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: @@ -77,12 +74,12 @@ const f32 = createFloat32Array(N); * @returns {{ buffer: Buffer, bufferUtf8: Buffer }} */ function createBuffers(f32) { - const buffer = Buffer.from(f32.buffer); - const ss = ta_to_hexStr(f32); - const bufferUtf8 = Buffer.from(ss, 'utf8'); // Encode ss in utf8 + 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 }; } -const { buffer, bufferUtf8 } = createBuffers(f32); +var { buffer, bufferUtf8 } = createBuffers(f32); /** * Serves up buffer at @@ -134,7 +131,7 @@ createServer(buffer, bufferUtf8); * @returns {Float32Array} */ function hexStr_to_ta(hexStr) { - const u8 = new Uint8Array(hexStr.length); + var u8 = new Uint8Array(hexStr.length); for (let k = 0; k < hexStr.length; k++) u8[k] = Number(hexStr.charCodeAt(k)); return new Float32Array(u8.buffer); @@ -163,9 +160,9 @@ function checkEnough(ta1, ta2, count = 1000) { return true; } -const xhr = new XMLHttpRequest(); -const url = "http://localhost:8888/binary"; -const urlUtf8 = "http://localhost:8888/binaryUtf8"; +var xhr = new XMLHttpRequest(); +var url = "http://localhost:8888/binary"; +var urlUtf8 = "http://localhost:8888/binaryUtf8"; /** * Send a GET request to the server. @@ -177,18 +174,18 @@ const urlUtf8 = "http://localhost:8888/binaryUtf8"; * @returns {Promise} */ function Get(url, isUtf8) { - return new Promise((resolve, reject) => { + return new Promise(function (resolve, reject) { xhr.open("GET", url, true); xhr.onloadend = function(event) { log('xhr.status:', xhr.status); if (xhr.status >= 200 && xhr.status < 300) { - const contentType = xhr.getResponseHeader('content-type'); + var contentType = xhr.getResponseHeader('content-type'); assert.equal(contentType, 'application/octet-stream'); - const dataTxt = xhr.responseText; - const data = xhr.response; + var dataTxt = xhr.responseText; + var data = xhr.response; assert(dataTxt && data); log('XHR GET:', contentType, dataTxt.length, data.length, data.toString('utf8').length); @@ -197,7 +194,7 @@ function Get(url, isUtf8) { if (isUtf8 && dataTxt.length !== data.toString('utf8').length) throw new Error("xhr.responseText !== xhr.response.toString('utf8')"); - const ta = isUtf8 ? new Float32Array(hexStr_to_ta(dataTxt)) : new Float32Array(data.buffer); + 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)) @@ -225,7 +222,7 @@ function Get(url, isUtf8) { */ function runTest() { return Get(urlUtf8, true) - .then(() => { return Get(url, false); }); + .then(function () { return Get(url, false); }); } /** @@ -233,8 +230,8 @@ function runTest() { */ setTimeout(function () { runTest() - .then((ta) => { console.log("done", ta?.length); }) - .finally(() => { + .then(function (ta) { console.log("done", ta?.length); }) + .finally(function () { if (serverProcess) serverProcess.close(); serverProcess = null; From acd5f6155598c22c6d849bf3f77c867146beec14 Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Wed, 23 Oct 2024 21:08:11 +0200 Subject: [PATCH 32/40] destroy agent after completing keepalive test and replace some const/let usages with var --- tests/test-keepalive.js | 4 +++- tests/test-utf8-tearing.js | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test-keepalive.js b/tests/test-keepalive.js index 0e5aac1..59f4d28 100644 --- a/tests/test-keepalive.js +++ b/tests/test-keepalive.js @@ -15,17 +15,19 @@ var url = "http://localhost:8889"; var repeats = 0; var maxMessages = 20; -const interval = setInterval(function sendRequest() { +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(); } if (repeats++ > maxMessages) { console.log('Done.'); clearInterval(interval); + agent.destroy(); server.close(); } } diff --git a/tests/test-utf8-tearing.js b/tests/test-utf8-tearing.js index 65e9319..681a051 100644 --- a/tests/test-utf8-tearing.js +++ b/tests/test-utf8-tearing.js @@ -45,7 +45,7 @@ var serverProcess; */ function ta_to_hexStr(ta) { var u8 = new Uint8Array(ta.buffer); - return u8.reduce(function (acc, cur) { return acc + String.fromCharCode(cur), ""}); + return u8.reduce(function (acc, cur) { return acc + String.fromCharCode(cur) }, ""); } /** @@ -56,8 +56,8 @@ function ta_to_hexStr(ta) { */ function createFloat32Array(N) { assert(N > 0); - let ta = new Float32Array(N); - for (let k = 0; k < ta.length; k++) + 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; @@ -132,7 +132,7 @@ createServer(buffer, bufferUtf8); */ function hexStr_to_ta(hexStr) { var u8 = new Uint8Array(hexStr.length); - for (let k = 0; k < hexStr.length; k++) + for (var k = 0; k < hexStr.length; k++) u8[k] = Number(hexStr.charCodeAt(k)); return new Float32Array(u8.buffer); } @@ -151,7 +151,7 @@ function checkEnough(ta1, ta2, count = 1000) { if (ta1.constructor.name !== ta2.constructor.name) return false; if (ta1.length !== ta2.length) return false; if (ta1.byteOffset !== ta2.byteOffset) return false; - for (let k = 0; k < Math.min(count, ta1.length); k++) { + 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; From bfa05a48d89da1b13b570de4cc114a6bf0fab3ba Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Wed, 23 Oct 2024 21:49:10 +0200 Subject: [PATCH 33/40] bump version to 2.1.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 369f688..f2971f7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "2.1.1", + "version": "2.1.2", "author": { "name": "Michael de Wit" }, From 6d64b64af232e10ae7866c2b4acfc2bde017dccf Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Wed, 23 Oct 2024 21:59:23 +0200 Subject: [PATCH 34/40] limit package files to main, license, and readme --- package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/package.json b/package.json index f2971f7..6c55abf 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,11 @@ "lib": "./lib", "example": "./example" }, + "files": [ + "lib/XMLHttpRequest.js", + "LICENSE", + "README.md" + ], "main": "./lib/XMLHttpRequest.js", "dependencies": {} } From 70949f7184c080b19062c1098382fa29b1e78d79 Mon Sep 17 00:00:00 2001 From: Paul F Ringseth Date: Thu, 14 Nov 2024 06:31:51 -0500 Subject: [PATCH 35/40] =?UTF-8?q?Full=20implementation=20of=20xhr.response?= =?UTF-8?q?Type,=20rigorous=20test=20and=20a=20perf=20imp=E2=80=A6=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Full implementation of xhr.responseType, rigorous test and a perf improvement. * Modified test-utf8-tearing.js to incorporate xhr.responseType changes. When the default xhr.responseType we only set xhr.responseText. To get the desired xhr.response we need to separately GET with xhr.responseType='arraybuffer'. * Remove document from xhr.responseType. Go back to setting xhr.response as well as xhr.responseText * Update tests to node 4 compatible * Fix sync requests for old node versions, fix server switch returns --------- Co-authored-by: paul Co-authored-by: YarnSaw --- lib/XMLHttpRequest.js | 244 ++++++++++-- tests/server.js | 39 +- tests/test-perf.js | 238 ++++++++++++ tests/test-request-protocols-binary-data.js | 100 +++-- tests/test-response-type.js | 401 ++++++++++++++++++++ tests/test-sync-response.js | 101 ++++- tests/test-utf8-tearing.js | 133 ++++--- 7 files changed, 1126 insertions(+), 130 deletions(-) create mode 100644 tests/test-perf.js create mode 100644 tests/test-response-type.js diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 8ede69a..372d3c5 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -134,6 +134,15 @@ function XMLHttpRequest(opts) { 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 */ @@ -158,6 +167,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 */ @@ -328,16 +402,17 @@ function XMLHttpRequest(opts) { self.handleError(error, error.errno || -1); } else { self.status = 200; - self.responseText = data.toString('utf8'); - self.response = data; + // Use self.responseType to create the correct self.responseType, self.response. + self.createFileOrSyncResponse(data); setState(self.DONE); } }); } else { try { - this.response = fs.readFileSync(unescape(url.pathname)); - this.responseText = this.response.toString('utf8'); this.status = 200; + const syncData = fs.readFileSync(unescape(url.pathname)); + // Use self.responseType to create the correct self.responseType, self.response. + this.createFileOrSyncResponse(syncData); setState(self.DONE); } catch(e) { this.handleError(e, e.errno || -1); @@ -422,6 +497,8 @@ function XMLHttpRequest(opts) { // 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 = []; // Check for redirect // @TODO Prevent looped redirects if (response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307) { @@ -457,13 +534,31 @@ function XMLHttpRequest(opts) { } 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) { - var data = Buffer.from(chunk); - self.response = Buffer.concat([self.response, data]); + 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) { @@ -476,10 +571,10 @@ function XMLHttpRequest(opts) { // 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); // Discard the 'end' event if the connection has been aborted setState(self.DONE); - // Construct responseText from response - self.responseText = self.response.toString('utf8'); } }); @@ -520,34 +615,46 @@ 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 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" + (ssl ? "s" : "") + ".request;" + "var options = " + JSON.stringify(options) + ";" - + "var responseText = '';" + "var responseData = Buffer.alloc(0);" + + "var buffers = [];" + "var req = doRequest(options, function(response) {" - + "response.on('data', function(chunk) {" - + " var data = Buffer.from(chunk);" - + " responseText += data.toString('utf8');" - + " responseData = Buffer.concat([responseData, data]);" - + "});" - + "response.on('end', function() {" - + "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText, data: responseData.toString('base64')}}), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + "response.on('error', function(error) {" - + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" + + " response.on('data', function(chunk) {" + + " buffers.push(chunk);" + + " });" + + " response.on('end', function() {" + + " responseData = concat(buffers);" + + " fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: {statusCode: response.statusCode, 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 + "');" + + " 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();"; // 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 } @@ -562,16 +669,19 @@ function XMLHttpRequest(opts) { 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"); - var resp = JSON.parse(self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, "$1")); + const resp = JSON.parse(self.responseText); + self.status = resp.data.statusCode; + 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 }; - self.responseText = resp.data.text; - self.response = Buffer.from(resp.data.data, 'base64'); - setState(self.DONE, true); + setState(self.DONE); } + } }; @@ -583,6 +693,8 @@ function XMLHttpRequest(opts) { this.status = status || 0; this.statusText = error; this.responseText = error.stack; + this.responseXML = ""; + this.response = Buffer.alloc(0); errorFlag = true; setState(this.DONE); }; @@ -655,6 +767,76 @@ function XMLHttpRequest(opts) { } }; + /** + * 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. * diff --git a/tests/server.js b/tests/server.js index 74ccf83..4649091 100644 --- a/tests/server.js +++ b/tests/server.js @@ -1,27 +1,34 @@ +'use strict'; var http = require("http"); var server = http.createServer(function (req, res) { switch (req.url) { case "/text": - return res - .writeHead(200, {"Content-Type": "text/plain"}) - .end("Hello world!"); + res.writeHead(200, {"Content-Type": "text/plain"}) + res.end("Hello world!"); + return; case "/xml": - return res - .writeHead(200, {"Content-Type": "application/xml"}) - .end("Foobar"); + res.writeHead(200, {"Content-Type": "application/xml"}) + res.end("Foobar"); + return; case "/json": - return res - .writeHead(200, {"Content-Type": "application/json"}) - .end(JSON.stringify({ foo: "bar" })); - case "/binary": - return res - .writeHead(200, {"Content-Type": "application/octet-stream"}) - .end(Buffer.from("Hello world!")); + 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: - return res - .writeHead(404, {"Content-Type": "text/plain"}) - .end("Not found"); + res.writeHead(404, {"Content-Type": "text/plain"}) + res.end("Not found"); } }).listen(8888); diff --git a/tests/test-perf.js b/tests/test-perf.js new file mode 100644 index 0000000..2bf9634 --- /dev/null +++ b/tests/test-perf.js @@ -0,0 +1,238 @@ + +/****************************************************************************************** + * This test measurs the elapsed time to download a Float32Array of length 100,000,000. + */ +// @ts-check +'use strict'; + +const http = require("http"); + +const XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; + +const supressConsoleOutput = false; +function log (_) { + if ( !supressConsoleOutput) + console.log(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"); + + 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-request-protocols-binary-data.js b/tests/test-request-protocols-binary-data.js index 4a3e42c..de14087 100644 --- a/tests/test-request-protocols-binary-data.js +++ b/tests/test-request-protocols-binary-data.js @@ -1,39 +1,81 @@ -var sys = require("util") - , assert = require("assert") - , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest - , xhr; +/** + * Test GET file URL with both async and sync mode. + * Use xhr.responseType = "arraybuffer". + */ +'use strict'; +var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest -xhr = new XMLHttpRequest(); - -xhr.onreadystatechange = function() { - if (this.readyState == 4) { - assert.equal("0000 803f 0000 a040 0000 c040 0000 e040", logBinary(this.response.toString('binary'))); - runSync(); - } -}; +const supressConsoleOutput = true; +function log (_) { + if ( !supressConsoleOutput) + console.log(arguments); +} -// Async var url = "file://" + __dirname + "/testBinaryData"; -xhr.open("GET", url); -xhr.send(); - -// Sync -var runSync = function() { - xhr = new XMLHttpRequest(); - - xhr.onreadystatechange = function() { - if (this.readyState == 4) { - assert.equal("0000 803f 0000 a040 0000 c040 0000 e040", logBinary(this.response.toString('binary'))); - console.log("done"); - } - }; - xhr.open("GET", url, false); - xhr.send(); + +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, /*isAsyn*/ 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, /*isAsyn*/ 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) { - return data.charCodeAt(idx).toString(16).padStart(2, '0'); + 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'; diff --git a/tests/test-response-type.js b/tests/test-response-type.js new file mode 100644 index 0000000..fe262da --- /dev/null +++ b/tests/test-response-type.js @@ -0,0 +1,401 @@ + +/****************************************************************************************** + * 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. + */ +// @ts-check +'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 index 4809076..316027e 100644 --- a/tests/test-sync-response.js +++ b/tests/test-sync-response.js @@ -1,33 +1,80 @@ +/** + * 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(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() + runTest(); + console.log('PASSED'); } catch (e) { - throw 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(); @@ -38,16 +85,58 @@ function runTest() { xhr.onreadystatechange = function () { if (xhr.readyState === 4) { - assert.equal(xhr.response.toString(), 'Hello world!'); + // 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/binary", false); + 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"); -} \ No newline at end of file +} diff --git a/tests/test-utf8-tearing.js b/tests/test-utf8-tearing.js index 681a051..8c801be 100644 --- a/tests/test-utf8-tearing.js +++ b/tests/test-utf8-tearing.js @@ -18,12 +18,12 @@ var assert = require("assert"); var http = require("http"); -var { XMLHttpRequest } = require("../lib/XMLHttpRequest"); +var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; var supressConsoleOutput = true; -function log (...args) { +function log (_) { if ( !supressConsoleOutput) - console.debug(...args); + console.log(arguments); } var serverProcess; @@ -79,7 +79,9 @@ function createBuffers(f32) { var bufferUtf8 = Buffer.from(ss, 'utf8'); // Encode ss in utf8 return { buffer, bufferUtf8 }; } -var { buffer, bufferUtf8 } = createBuffers(f32); +var bufs = createBuffers(f32); +var buffer = bufs.buffer, + bufferUtf8 = bufs.bufferUtf8 /** * Serves up buffer at @@ -94,17 +96,17 @@ function createServer(buffer, bufferUtf8) { serverProcess = http.createServer(function (req, res) { switch (req.url) { case "/binary": - return res - .writeHead(200, {"Content-Type": "application/octet-stream"}) - .end(buffer); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(buffer); + return; case "/binaryUtf8": - return res - .writeHead(200, {"Content-Type": "application/octet-stream"}) - .end(bufferUtf8); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(bufferUtf8); + return; default: - return res - .writeHead(404, {"Content-Type": "text/plain"}) - .end("Not found"); + res.writeHead(404, {"Content-Type": "text/plain"}) + res.end("Not found"); + return; } }).listen(8888); process.on("SIGINT", function () { @@ -146,7 +148,9 @@ function hexStr_to_ta(hexStr) { * @param {number} [count=1000] * @returns {boolean} */ -function checkEnough(ta1, ta2, count = 1000) { +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; @@ -164,6 +168,43 @@ 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 @@ -174,37 +215,26 @@ var urlUtf8 = "http://localhost:8888/binaryUtf8"; * @returns {Promise} */ function Get(url, isUtf8) { - return new Promise(function (resolve, reject) { - xhr.open("GET", url, true); - xhr.onloadend = function(event) { - - log('xhr.status:', xhr.status); - - if (xhr.status >= 200 && xhr.status < 300) { - var contentType = xhr.getResponseHeader('content-type'); - assert.equal(contentType, 'application/octet-stream'); - - var dataTxt = xhr.responseText; - var data = xhr.response; - assert(dataTxt && data); - - log('XHR GET:', contentType, 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"); - - resolve(ta); - } - reject(new Error(`Request failed: xhr.status ${xhr.status}`)); - } - xhr.send(); + 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; + }) }); } @@ -230,11 +260,18 @@ function runTest() { */ setTimeout(function () { runTest() - .then(function (ta) { console.log("done", ta?.length); }) - .finally(function () { + .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); From cfd991f03542c8610a8a01f96bb6c2ffac26b84b Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Thu, 14 Nov 2024 13:10:23 +0100 Subject: [PATCH 36/40] bump version to 3.0.0 and set minimum Node.js version to 12 --- .github/workflows/test.yml | 2 +- package.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d12e24e..555b1b7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [14.x, 16.x, 18.x, 20.x] + node-version: [12.x, 20.x, 22.x, 23.x] steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} diff --git a/package.json b/package.json index 6c55abf..4bb493a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "2.1.2", + "version": "3.0.0", "author": { "name": "Michael de Wit" }, @@ -21,7 +21,7 @@ }, "bugs": "http://github.com/mjwwit/node-XMLHttpRequest/issues", "engines": { - "node": ">=0.4.0" + "node": ">=12.0.0" }, "scripts": { "test": "cd ./tests && node test-constants.js && node test-events.js && node test-exceptions.js && node test-headers.js && node test-redirect-302.js && node test-redirect-303.js && node test-redirect-307.js && node test-request-methods.js && node test-request-protocols-txt-data.js && node test-request-protocols-binary-data.js && node test-sync-response.js && node test-utf8-tearing.js && node test-keepalive.js" From c5f1fc3461be790bd3038e461d22c419e147b200 Mon Sep 17 00:00:00 2001 From: Frederick Lanford Date: Sat, 7 Dec 2024 20:57:17 +0900 Subject: [PATCH 37/40] Data URI support, redirect checks and more (#26) * Data URI support, redirect checks and more * add tests * Fix one of the tests * allowFileSystemSources --> allowFileSystemResources * Add origin options * resolve some issues * little docfix for params * Updates to reflect new reviews * Update lib/XMLHttpRequest.js Co-authored-by: Michael de Wit * Update lib/XMLHttpRequest.js Co-authored-by: Michael de Wit * Change from error code to error messages + newlines * Missing strict equality somewhere * Create automated test script * change identation of test runner * add strict equality in redirect code checks --------- Co-authored-by: Michael de Wit --- lib/XMLHttpRequest.js | 380 +++++++++++++++++++++------- package.json | 2 +- tests/run-test.js | 50 ++++ tests/server.js | 22 +- tests/test-data-uri.js | 131 ++++++++++ tests/test-disallow-fs-resources.js | 36 +++ tests/test-max-redirects.js | 47 ++++ tests/test-perf.js | 14 +- tests/test-redirect-301.js | 41 +++ tests/test-redirect-302.js | 2 +- tests/test-redirect-303.js | 2 +- tests/test-redirect-307.js | 2 +- tests/test-redirect-308.js | 41 +++ tests/test-sync-response.js | 2 +- tests/test-unsafe-redirect.js | 41 +++ tests/test-url-origin.js | 47 ++++ 16 files changed, 752 insertions(+), 108 deletions(-) create mode 100644 tests/run-test.js create mode 100644 tests/test-data-uri.js create mode 100644 tests/test-disallow-fs-resources.js create mode 100644 tests/test-max-redirects.js create mode 100644 tests/test-redirect-301.js create mode 100644 tests/test-redirect-308.js create mode 100644 tests/test-unsafe-redirect.js create mode 100644 tests/test-url-origin.js diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 372d3c5..73eb896 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; /** @@ -37,7 +36,34 @@ XMLHttpRequest.XMLHttpRequest = XMLHttpRequest; function XMLHttpRequest(opts) { "use strict"; - opts = opts || {}; + // 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, + max_redirects: 20, + 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 @@ -46,6 +72,12 @@ function XMLHttpRequest(opts) { var http = require('http'); var https = require('https'); + var max_redirects = opts.maxRedirects; + if (typeof max_redirects !== 'number' || Number.isNaN(max_redirects)) max_redirects = 20; + else max_redirects = Math.max(max_redirects, 0); + + var redirect_count = 0; + // Holds http.js objects var request; var response; @@ -130,6 +162,7 @@ function XMLHttpRequest(opts) { // Result & response this.responseText = ""; this.responseXML = ""; + this.responseURL = ""; this.response = Buffer.alloc(0); this.status = null; this.statusText = null; @@ -256,13 +289,21 @@ function XMLHttpRequest(opts) { } 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); }; @@ -305,10 +346,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()]; } @@ -322,7 +366,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 = ""; @@ -333,7 +378,7 @@ function XMLHttpRequest(opts) { result += i + ": " + response.headers[i] + "\r\n"; } } - return result.substr(0, result.length - 2); + return result.slice(0, -2); }; /** @@ -351,6 +396,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. * @@ -365,20 +448,37 @@ function XMLHttpRequest(opts) { 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: @@ -391,7 +491,25 @@ function XMLHttpRequest(opts) { } // 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 new Error("XMLHttpRequest: Only GET method is supported"); } @@ -402,6 +520,7 @@ function XMLHttpRequest(opts) { self.handleError(error, error.errno || -1); } else { self.status = 200; + self.responseURL = settings.url; // Use self.responseType to create the correct self.responseType, self.response. self.createFileOrSyncResponse(data); setState(self.DONE); @@ -412,6 +531,7 @@ function XMLHttpRequest(opts) { 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) { @@ -424,22 +544,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"); } @@ -459,32 +579,21 @@ function XMLHttpRequest(opts) { headers["Content-Length"] = 0; } - var agent = opts.agent || false; 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 === false ? false : true; - } - // 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; @@ -494,45 +603,67 @@ function XMLHttpRequest(opts) { // Handler for the response var responseHandler = function(resp) { - // 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 = []; // 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 + ) { + ++redirect_count; + // end the response + resp.destroy(); + if (redirect_count > max_redirects) { + 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) { - newOptions.pfx = opts.pfx; - newOptions.key = opts.key; - newOptions.passphrase = opts.passphrase; - newOptions.cert = opts.cert; - newOptions.ca = opts.ca; - newOptions.ciphers = opts.ciphers; - newOptions.rejectUnauthorized = opts.rejectUnauthorized === false ? false : true; - } - // 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; } + // 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. @@ -564,7 +695,7 @@ function XMLHttpRequest(opts) { if (sendFlag) { setState(self.LOADING); } - }); + }.bind(response)); response.on('end', function() { if (sendFlag) { @@ -573,14 +704,16 @@ function XMLHttpRequest(opts) { 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); } - }); + }.bind(response)); response.on('error', function(error) { self.handleError(error); - }); + }.bind(response)); } // Error handler for the request @@ -592,21 +725,28 @@ function XMLHttpRequest(opts) { 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); - if (opts.autoUnref) { - request.on('socket', (socket) => { - socket.unref(); - }); - } + request = doRequest(opt, responseHandler).on('error', errorHandler); - // Node 0.4 and later won't accept empty data. Make sure it's needed. - if (data) { - request.write(data); + 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); + } + + request.end(); } - request.end(); + // Create the request + createRequest(options); self.dispatchEvent("loadstart"); } else { // Synchronous @@ -630,29 +770,70 @@ function XMLHttpRequest(opts) { + " }" + " return result;" + "};" - + "var doRequest = http" + (ssl ? "s" : "") + ".request;" + + "var doRequest = http" + (isSsl ? "s" : "") + ".request;" + + "var isSsl = " + !!isSsl + ";" + "var options = " + JSON.stringify(options) + ";" + + "var sslOptions = " + JSON.stringify(sslOptions) + ";" + "var responseData = Buffer.alloc(0);" + "var buffers = [];" - + "var req = doRequest(options, function(response) {" - + " response.on('data', function(chunk) {" - + " buffers.push(chunk);" - + " });" - + " response.on('end', function() {" - + " responseData = concat(buffers);" - + " fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, data: responseData.toString('utf8')}}), 'utf8');" - + " fs.unlinkSync('" + syncFile + "');" - + " });" - + " response.on('error', function(error) {" + + "var url = new URL(" + JSON.stringify(settings.url) + ");" + + "var max_redirects = " + max_redirects + ", 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 > max_redirects) {" + + " 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 + "');" + " });" - + "}).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();"; + + " " + (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]); while(fs.existsSync(syncFile)) { @@ -663,14 +844,21 @@ 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 = JSON.parse(self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, "")); - self.handleError(errorObj, 503); + 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 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); @@ -681,7 +869,6 @@ function XMLHttpRequest(opts) { }; setState(self.DONE); } - } }; @@ -691,12 +878,14 @@ function XMLHttpRequest(opts) { */ this.handleError = function(error, status) { this.status = status || 0; - this.statusText = error; - this.responseText = error.stack; + 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; }; /** @@ -751,18 +940,19 @@ function XMLHttpRequest(opts) { * Dispatch any events, including both "on" methods and events attached using addEventListener. */ this.dispatchEvent = function (event) { + let argument = { type: event }; if (typeof self["on" + event] === "function") { if (this.readyState === this.DONE && settings.async) - setTimeout(function() { self["on" + event]() }, 0) + setTimeout(function() { self["on" + event](argument) }, 0) else - self["on" + event]() + self["on" + event](argument) } if (event in listeners) { for (let i = 0, len = listeners[event].length; i < len; i++) { if (this.readyState === this.DONE) - setTimeout(function() { listeners[event][i].call(self) }, 0) + setTimeout(function() { listeners[event][i].call(self, argument) }, 0) else - listeners[event][i].call(self) + listeners[event][i].call(self, argument) } } }; diff --git a/package.json b/package.json index 4bb493a..2b796fd 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "node": ">=12.0.0" }, "scripts": { - "test": "cd ./tests && node test-constants.js && node test-events.js && node test-exceptions.js && node test-headers.js && node test-redirect-302.js && node test-redirect-303.js && node test-redirect-307.js && node test-request-methods.js && node test-request-protocols-txt-data.js && node test-request-protocols-binary-data.js && node test-sync-response.js && node test-utf8-tearing.js && node test-keepalive.js" + "test": "cd ./tests && node run-test.js" }, "directories": { "lib": "./lib", 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 index 4649091..e76752b 100644 --- a/tests/server.js +++ b/tests/server.js @@ -3,6 +3,17 @@ 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!"); @@ -27,8 +38,15 @@ var server = http.createServer(function (req, res) { res.end(str); return; default: - res.writeHead(404, {"Content-Type": "text/plain"}) - res.end("Not found"); + 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); diff --git a/tests/test-data-uri.js b/tests/test-data-uri.js new file mode 100644 index 0000000..3344008 --- /dev/null +++ b/tests/test-data-uri.js @@ -0,0 +1,131 @@ +var sys = require("util") + , 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(" --> SUCESS"); + ++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) }; + 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..47a8696 --- /dev/null +++ b/tests/test-disallow-fs-resources.js @@ -0,0 +1,36 @@ +var sys = require("util") + , 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) console.error(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-max-redirects.js b/tests/test-max-redirects.js new file mode 100644 index 0000000..d221001 --- /dev/null +++ b/tests/test-max-redirects.js @@ -0,0 +1,47 @@ +var sys = require("util") + , 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); + } + + 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) { + if (e.message !== 'Too many redirects') console.log("ERROR: Exception raised", e); + } +} + +setTimeout(function () { + try { + runTest(); + } finally { + serverProcess.kill('SIGINT'); + } +}, 100); diff --git a/tests/test-perf.js b/tests/test-perf.js index 2bf9634..488b12a 100644 --- a/tests/test-perf.js +++ b/tests/test-perf.js @@ -12,7 +12,7 @@ const XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; const supressConsoleOutput = false; function log (_) { if ( !supressConsoleOutput) - console.log(arguments); + console.log.apply(console, arguments); } var serverProcess; @@ -94,12 +94,14 @@ function createServer() { res.end(`success:len ${u8.length}`); }); } else { - if (!storage[req.url]) + if (!storage[req.url]) { res.writeHead(404, {"Content-Type": "text/plain; charset=utf8"}) res.end("Not in storage"); - - res.writeHead(200, {"Content-Type": "application/octet-stream"}) - res.end(storage[req.url]); + } + else { + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(storage[req.url]); + } } }).listen(8888); process.on("SIGINT", function () { @@ -216,7 +218,7 @@ function afterUpload(r) { 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...']) + console.info('...waiting to see elapsed time of download...'); if (!success) throw new Error("Download has taken far too long!"); } diff --git a/tests/test-redirect-301.js b/tests/test-redirect-301.js new file mode 100644 index 0000000..0b3efe3 --- /dev/null +++ b/tests/test-redirect-301.js @@ -0,0 +1,41 @@ +var sys = require("util") + , 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); +} diff --git a/tests/test-redirect-302.js b/tests/test-redirect-302.js index 0b87192..ca69e0f 100644 --- a/tests/test-redirect-302.js +++ b/tests/test-redirect-302.js @@ -26,7 +26,7 @@ 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"); console.log("done"); diff --git a/tests/test-redirect-303.js b/tests/test-redirect-303.js index aa85b2d..c6ce0cf 100644 --- a/tests/test-redirect-303.js +++ b/tests/test-redirect-303.js @@ -26,7 +26,7 @@ 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"); console.log("done"); diff --git a/tests/test-redirect-307.js b/tests/test-redirect-307.js index a73819f..4eb4a2f 100644 --- a/tests/test-redirect-307.js +++ b/tests/test-redirect-307.js @@ -28,7 +28,7 @@ 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"); console.log("done"); diff --git a/tests/test-redirect-308.js b/tests/test-redirect-308.js new file mode 100644 index 0000000..71543e0 --- /dev/null +++ b/tests/test-redirect-308.js @@ -0,0 +1,41 @@ +var sys = require("util") + , 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); +} diff --git a/tests/test-sync-response.js b/tests/test-sync-response.js index 316027e..734fe01 100644 --- a/tests/test-sync-response.js +++ b/tests/test-sync-response.js @@ -12,7 +12,7 @@ var assert = require("assert") const supressConsoleOutput = true; function log (_) { if ( !supressConsoleOutput) - console.log(arguments); + console.log.apply(console, arguments); } // Running a sync XHR and a webserver within the same process will cause a deadlock diff --git a/tests/test-unsafe-redirect.js b/tests/test-unsafe-redirect.js new file mode 100644 index 0000000..54fff68 --- /dev/null +++ b/tests/test-unsafe-redirect.js @@ -0,0 +1,41 @@ +var sys = require("util") + , 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); +} diff --git a/tests/test-url-origin.js b/tests/test-url-origin.js new file mode 100644 index 0000000..c86abb7 --- /dev/null +++ b/tests/test-url-origin.js @@ -0,0 +1,47 @@ +var sys = require("util") + , 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); + } +} + +setTimeout(function () { + try { + runTest(); + } finally { + serverProcess.kill('SIGINT'); + } +}, 100); From 76de70f61b8644484c9e5c40ec613bc222c27759 Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Sun, 8 Dec 2024 10:05:14 +0100 Subject: [PATCH 38/40] fix: use maxRedirects instead of max_redirects, failing tests now result in non-zero exit-code, add docs for new options, bump version to 3.1.0 --- README.md | 20 ++++++++++++-------- lib/XMLHttpRequest.js | 17 ++++++++--------- package.json | 2 +- tests/test-constants.js | 3 +-- tests/test-data-uri.js | 10 ++++++---- tests/test-disallow-fs-resources.js | 11 +++++++---- tests/test-events.js | 3 +-- tests/test-exceptions.js | 9 ++++----- tests/test-headers.js | 6 +++--- tests/test-keepalive.js | 2 ++ tests/test-max-redirects.js | 6 +++--- tests/test-perf.js | 1 - tests/test-redirect-301.js | 4 ++-- tests/test-redirect-302.js | 4 ++-- tests/test-redirect-303.js | 4 ++-- tests/test-redirect-307.js | 4 ++-- tests/test-redirect-308.js | 4 ++-- tests/test-request-methods.js | 3 +-- tests/test-request-protocols-binary-data.js | 4 ++-- tests/test-request-protocols-txt-data.js | 3 +-- tests/test-response-type.js | 1 - tests/test-unsafe-redirect.js | 4 ++-- tests/test-url-origin.js | 4 ++-- tests/test-utf8-tearing.js | 1 - 24 files changed, 66 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 7e09b94..c6c7912 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,22 @@ XHR object. Note: use the lowercase string "xmlhttprequest-ssl" in your require(). On case-sensitive systems (eg Linux) using uppercase letters won't work. -# Original README # -## Usage ## +### Non-standard features ### -Here's how to include the module in your project and use as the browser-based -XHR object. +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). - var XMLHttpRequest = require("xmlhttprequest").XMLHttpRequest; - var xhr = new XMLHttpRequest(); +Additionally, the `agent` option allows you to specify a [Node.js Agent](https://nodejs.org/api/https.html#class-httpsagent) instance, allowing connection reuse. -Note: use the lowercase string "xmlhttprequest" in your require(). On -case-sensitive systems (eg Linux) using uppercase letters won't work. +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 ## diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 73eb896..8bbc904 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -47,7 +47,6 @@ function XMLHttpRequest(opts) { rejectUnauthorized: true, autoUnref: false, agent: undefined, - max_redirects: 20, allowFileSystemResources: true, maxRedirects: 20, // Chrome standard origin: undefined @@ -72,11 +71,11 @@ function XMLHttpRequest(opts) { var http = require('http'); var https = require('https'); - var max_redirects = opts.maxRedirects; - if (typeof max_redirects !== 'number' || Number.isNaN(max_redirects)) max_redirects = 20; - else max_redirects = Math.max(max_redirects, 0); + var maxRedirects = opts.maxRedirects; + if (typeof maxRedirects !== 'number' || Number.isNaN(maxRedirects)) maxRedirects = 20; + else maxRedirects = Math.max(maxRedirects, 0); - var redirect_count = 0; + var redirectCount = 0; // Holds http.js objects var request; @@ -611,10 +610,10 @@ function XMLHttpRequest(opts) { resp.statusCode === 307 || resp.statusCode === 308 ) { - ++redirect_count; + ++redirectCount; // end the response resp.destroy(); - if (redirect_count > max_redirects) { + if (redirectCount > maxRedirects) { self.handleError(new Error("Too many redirects")); return; } @@ -777,7 +776,7 @@ function XMLHttpRequest(opts) { + "var responseData = Buffer.alloc(0);" + "var buffers = [];" + "var url = new URL(" + JSON.stringify(settings.url) + ");" - + "var max_redirects = " + max_redirects + ", redirects_count = 0;" + + "var maxRedirects = " + maxRedirects + ", redirects_count = 0;" + "var makeRequest = function () {" + " var opt = Object.assign({}, options);" + " if (isSsl) Object.assign(opt, sslOptions);" @@ -785,7 +784,7 @@ function XMLHttpRequest(opts) { + " if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307 || response.statusCode === 308) {" + " response.destroy();" + " ++redirects_count;" - + " if (redirects_count > max_redirects) {" + + " if (redirects_count > maxRedirects) {" + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR-REDIRECT: Too many redirects', 'utf8');" + " fs.unlinkSync('" + syncFile + "');" + " return;" diff --git a/package.json b/package.json index 2b796fd..8db9dfb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "3.0.0", + "version": "3.1.0", "author": { "name": "Michael de Wit" }, diff --git a/tests/test-constants.js b/tests/test-constants.js index 4c697e2..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(); diff --git a/tests/test-data-uri.js b/tests/test-data-uri.js index 3344008..c94dd23 100644 --- a/tests/test-data-uri.js +++ b/tests/test-data-uri.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr; @@ -99,7 +98,7 @@ var runSyncTest = function (test) { assert.equal(xhr.status, 0); assert.equal(e.message, test.error); } - console.log(" --> SUCESS"); + console.log(" --> SUCCESS"); ++tests_passed; } @@ -121,7 +120,10 @@ var startTest = function () { try { runSyncTest(test); } - catch (e) { console.error(e) }; + catch (e) { + console.error(e); + throw e; + }; console.log(""); ++i; startTest(); diff --git a/tests/test-disallow-fs-resources.js b/tests/test-disallow-fs-resources.js index 47a8696..a244849 100644 --- a/tests/test-disallow-fs-resources.js +++ b/tests/test-disallow-fs-resources.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr; @@ -9,8 +8,12 @@ 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) console.error(e); + try { + runSync(); + } catch (e) { + if (e instanceof assert.AssertionError) { + throw e; + } } } }; diff --git a/tests/test-events.js b/tests/test-events.js index 3296f8b..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; diff --git a/tests/test-exceptions.js b/tests/test-exceptions.js index 10eaea2..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 @@ -49,7 +48,7 @@ var forbiddenRequestHeaders = [ for (var i in forbiddenRequestHeaders) { if(xhr.setRequestHeader(forbiddenRequestHeaders[i], "Test") !== false) { - console.log("ERROR: " + forbiddenRequestHeaders[i] + " should have thrown exception"); + throw new Error("ERROR: " + forbiddenRequestHeaders[i] + " should have thrown exception"); } } diff --git a/tests/test-headers.js b/tests/test-headers.js index 90a0576..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"); @@ -77,5 +76,6 @@ try { 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 index 59f4d28..a5b09e7 100644 --- a/tests/test-keepalive.js +++ b/tests/test-keepalive.js @@ -1,3 +1,4 @@ +var assert = require("assert"); var http = require('http'); var { XMLHttpRequest } = require("../lib/XMLHttpRequest"); @@ -23,6 +24,7 @@ var interval = setInterval(function sendRequest() { clearInterval(interval); agent.destroy(); server.close(); + assert.equal(xhr.status, 200); } if (repeats++ > maxMessages) { console.log('Done.'); diff --git a/tests/test-max-redirects.js b/tests/test-max-redirects.js index d221001..9938528 100644 --- a/tests/test-max-redirects.js +++ b/tests/test-max-redirects.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , spawn = require('child_process').spawn; @@ -20,6 +19,7 @@ var runTest = function () { xhr.send(); } catch(e) { console.log("ERROR: Exception raised", e); + throw e; } try { @@ -34,7 +34,7 @@ var runTest = function () { }; xhr.send(); } catch(e) { - if (e.message !== 'Too many redirects') console.log("ERROR: Exception raised", e); + assert.equal(e.message, 'Too many redirects'); } } diff --git a/tests/test-perf.js b/tests/test-perf.js index 488b12a..b93fd97 100644 --- a/tests/test-perf.js +++ b/tests/test-perf.js @@ -2,7 +2,6 @@ /****************************************************************************************** * This test measurs the elapsed time to download a Float32Array of length 100,000,000. */ -// @ts-check 'use strict'; const http = require("http"); diff --git a/tests/test-redirect-301.js b/tests/test-redirect-301.js index 0b3efe3..91ec4cf 100644 --- a/tests/test-redirect-301.js +++ b/tests/test-redirect-301.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr = new XMLHttpRequest() , http = require("http"); @@ -38,4 +37,5 @@ try { 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 ca69e0f..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"); @@ -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 c6ce0cf..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"); @@ -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 4eb4a2f..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"); @@ -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 index 71543e0..d517f68 100644 --- a/tests/test-redirect-308.js +++ b/tests/test-redirect-308.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"); @@ -38,4 +37,5 @@ try { 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 f8d66d8..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; diff --git a/tests/test-request-protocols-binary-data.js b/tests/test-request-protocols-binary-data.js index de14087..1a10344 100644 --- a/tests/test-request-protocols-binary-data.js +++ b/tests/test-request-protocols-binary-data.js @@ -38,7 +38,7 @@ function download (url, isAsync) { function runTest () { // Async - var ab = download(url, /*isAsyn*/ true); + var ab = download(url, /*isAsync*/ true); return ab.then(afterAsyncDownload); } @@ -51,7 +51,7 @@ function afterAsyncDownload(ab) { log("done async phase"); // Sync - var abSync = download(url, /*isAsyn*/ false); + var abSync = download(url, /*isAsync*/ false); return abSync.then(afterSyncDownload); } diff --git a/tests/test-request-protocols-txt-data.js b/tests/test-request-protocols-txt-data.js index f745cb8..8164333 100644 --- a/tests/test-request-protocols-txt-data.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; diff --git a/tests/test-response-type.js b/tests/test-response-type.js index fe262da..6b4f39f 100644 --- a/tests/test-response-type.js +++ b/tests/test-response-type.js @@ -12,7 +12,6 @@ * [ "text", "", "arraybuffer", "blob", "json" ] * and then various checks verify that the downloaded content is the same as that uploaded. */ -// @ts-check 'use strict'; const http = require("http"); diff --git a/tests/test-unsafe-redirect.js b/tests/test-unsafe-redirect.js index 54fff68..065d808 100644 --- a/tests/test-unsafe-redirect.js +++ b/tests/test-unsafe-redirect.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"); @@ -38,4 +37,5 @@ try { 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 index c86abb7..ff89b2c 100644 --- a/tests/test-url-origin.js +++ b/tests/test-url-origin.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , spawn = require('child_process').spawn; @@ -35,6 +34,7 @@ var runTest = function () { xhr.send(); } catch(e) { console.log("ERROR: Exception raised", e); + throw e; } } diff --git a/tests/test-utf8-tearing.js b/tests/test-utf8-tearing.js index 8c801be..79a6d46 100644 --- a/tests/test-utf8-tearing.js +++ b/tests/test-utf8-tearing.js @@ -13,7 +13,6 @@ * // Construct responseText from response * self.responseText = self.response.toString('utf8'); */ -// @ts-check 'use strict'; var assert = require("assert"); From 99985d25e185f54a467bdfbcb86c644d57f75b3e Mon Sep 17 00:00:00 2001 From: Frederick Lanford Date: Sat, 17 May 2025 16:52:20 +0900 Subject: [PATCH 39/40] sync policy, MIME override, encoding support, and more (#31) * [draft] syncPolicy, MIME support, and more * make disableHeaderCheck an option and add deprecation for xhr.getRequestHeader * check which version does not support iso-8859-1 * add dispatch * Add barebone support for extensive text decoding and xml parsing * bump minimum node version to v13, fix syncPolicy for non-async and add basic anti-pollution measure --- .github/workflows/test.yml | 6 +- README.md | 101 ++++- lib/XMLHttpRequest.js | 829 ++++++++++++++++++++---------------- package.json | 2 +- tests/server.js | 39 +- tests/test-constants.js | 6 + tests/test-data-uri.js | 19 +- tests/test-headers.js | 51 ++- tests/test-keepalive.js | 2 +- tests/test-max-redirects.js | 2 +- tests/test-mimetype.js | 168 ++++++++ tests/test-pollution.js | 61 +++ tests/test-redirect-301.js | 2 +- tests/test-redirect-302.js | 2 +- tests/test-redirect-303.js | 2 +- tests/test-redirect-307.js | 2 +- tests/test-redirect-308.js | 2 +- tests/test-sync-flag.js | 107 +++++ tests/test-sync-response.js | 19 +- 19 files changed, 977 insertions(+), 445 deletions(-) create mode 100644 tests/test-mimetype.js create mode 100644 tests/test-pollution.js create mode 100644 tests/test-sync-flag.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 555b1b7..541b904 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,16 +4,18 @@ on: branches: [ master ] pull_request: branches: [ master ] + workflow_dispatch: jobs: integration-tests: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - node-version: [12.x, 20.x, 22.x, 23.x] + node-version: [13.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 + - run: npm test diff --git a/README.md b/README.md index c6c7912..0b5babb 100644 --- a/README.md +++ b/README.md @@ -13,19 +13,94 @@ XHR object. 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)`. +## Non-standard features ## +### Additional options ### + +Non-standard options for this module are passed through the `XMLHttpRequest` constructor. Here is the list of all options: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionDefault valueDescription
caundefinedControl https: requests, you can find their functionality in the Nodejs Documentation
cert
ciphers
key
passhphrase
pfx
rejectUnauthorizedtrue
agentundefinedAllows to specify a Nodejs Agent instance, allowing connection reuse
autoUnreffalseSet to any truthy value to prevent a process from not exiting naturally because a request socket from this module is still open
maxRedirects20Allows control over the maximum number of redirects that are followed
allowFileSystemResourcestrueAllows control access to the local filesystem through the file: protocol
originundefinedSet a base URL for the requests called using this instance. The resulting request URL will be constructed as follows: new URL(url, origin)
syncPolicy"warn"Control feature behavior of the synchronous feature:
  • "disabled": Disable the feature completely, throws error after calling .send() if in synchronous mode
  • "warn": Enable the feature, but show a warning when calling .open() with synchronous mode
  • "enabled": Enable the feature without showing any additional warnings or errors
disableHeaderCheckfalseDisable the check against forbidden headers to be added to a XHR request
xmlParsernoneSpecify a parser (non-async) to parse document from text when xhr.responseType is either "document" or in text format. If the parser is invalid or omitted, xhr.responseXML will be null
textDecoderTextDecoder or buf.toString(enc) depending on Node versionSpecify a text decoder (non-async), accepting a buffer buf and encoding enc to decode to desired encoding.
Note that TextDecoder at version 12 does not support a wide range of encodings than later node version does
+ +### Additional methods ### +`XMLHttpRequest` object created using this library exposes `xhr.getRequestHeader(header_name)` method to retrieve any header content by name in the request headers list. This feature is deprecated and will be removed in future releases. # Original README # diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 8bbc904..f37edf0 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -12,7 +12,31 @@ */ var fs = require('fs'); +var os = require('os'); +var path = require('path'); var spawn = require('child_process').spawn; +/** + * Constants + */ + +var stateConstants = { + UNSENT: 0, + OPENED: 1, + HEADERS_RECEIVED: 2, + LOADING: 3, + DONE: 4 +}; + +var assignStateConstants = function (object) { + for (let stateKey in stateConstants) Object.defineProperty(object, stateKey, { + enumerable: true, + writable: false, + configurable: false, + value: stateConstants[stateKey] + }); +} + +assignStateConstants(XMLHttpRequest); /** * Module exports. @@ -36,6 +60,40 @@ XMLHttpRequest.XMLHttpRequest = XMLHttpRequest; function XMLHttpRequest(opts) { "use strict"; + if (!new.target) { + throw new TypeError("Failed to construct 'XMLHttpRequest': Please use the 'new' operator, this object constructor cannot be called as a function."); + } + + var dataMap = Object.create(null); + + /** + * Safely assign any key with value to an object, preventing prototype pollution + * @param {any} obj Object to assign + * @param {any} key key name + * @param {any} value value to assign + * @param {boolean} assignable whether user can change this value (this defaults to `true` when value is a function) + */ + var assignProp = function (obj, key, value, assignable) { + if ("function" === typeof value) Object.defineProperty(obj, key, { + value: value, + writable: true, + enumerable: true, + configurable: true + }); + else if (assignable) Object.defineProperty(obj, key, { + get: function () { return dataMap[key]; }, + set: function (value) { dataMap[key] = value; }, + enumerable: true, + configurable: true + }); + else Object.defineProperty(obj, key, { + get: function () { return dataMap[key]; }, + set: undefined, + enumerable: true, + configurable: true + }); + } + // defines a list of default options to prevent parameters pollution var default_options = { pfx: undefined, @@ -49,10 +107,33 @@ function XMLHttpRequest(opts) { agent: undefined, allowFileSystemResources: true, maxRedirects: 20, // Chrome standard + syncPolicy: "warn", + disableHeaderCheck: false, + xmlParser: function (text) { + return null; + }, + textDecoder: function (buf, enc) { + if ("function" === typeof TextDecoder) try { + return new TextDecoder(enc).decode(buf); + } + catch (e) {} + return buf.toString(enc); + }, origin: undefined }; - opts = Object.assign(default_options, opts); + opts = Object.assign(Object.create(null), default_options, opts); + + if (opts.syncPolicy !== "warn" && opts.syncPolicy !== "disabled" && opts.syncPolicy !== "enabled") { + opts.syncPolicy = "warn"; + } + + for (var i of ["xmlParser", "textDecoder"]) { + if (typeof opts[i] !== "function") { + //@TODO: find a reliable way to check if function is async + opts[i] = default_options[i]; + } + } var sslOptions = { pfx: opts.pfx, @@ -82,11 +163,9 @@ function XMLHttpRequest(opts) { var response; // Request settings - var settings = {}; + var settings = Object.create(null); - // Disable header blacklist. - // Not part of XHR specs. - var disableHeaderCheck = false; + assignStateConstants(this); // Set some default headers var defaultHeaders = { @@ -94,7 +173,7 @@ function XMLHttpRequest(opts) { "Accept": "*/*" }; - var headers = Object.assign({}, defaultHeaders); + var headers = Object.assign(Object.create(null), defaultHeaders); // These headers are not user setable. // The following are allowed but banned in the spec: @@ -135,36 +214,35 @@ function XMLHttpRequest(opts) { var errorFlag = false; var abortedFlag = false; - // Event listeners - var listeners = {}; + // Custom encoding (if user called via xhr.overrideMimeType) + var customEncoding = ""; - /** - * Constants - */ + // Event listeners + var listeners = Object.create(null); - this.UNSENT = 0; - this.OPENED = 1; - this.HEADERS_RECEIVED = 2; - this.LOADING = 3; - this.DONE = 4; + // private ready state (not exposed so user cannot modify) + var readyState = this.UNSENT; /** * Public vars */ - // Current state - this.readyState = this.UNSENT; + Object.defineProperty(this, "readyState", { + get: function () { return readyState; }, + configurable: true, + enumerable: true + }); // default ready state change handler in case one is not set or is set late - this.onreadystatechange = null; + assignProp(this, 'onreadystatechange', null, true); // Result & response - this.responseText = ""; - this.responseXML = ""; - this.responseURL = ""; - this.response = Buffer.alloc(0); - this.status = null; - this.statusText = null; + assignProp(this, 'responseText', ""); + assignProp(this, "responseXML", ""); + assignProp(this, "responseURL", ""); + assignProp(this, "response", Buffer.alloc(0)); + assignProp(this, "status", null); + assignProp(this, "statusText", null); // xhr.responseType is supported: // When responseType is 'text' or '', self.responseText will be utf8 decoded text. @@ -173,7 +251,7 @@ function XMLHttpRequest(opts) { // 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' */ + assignProp(this, "responseType", "", true); /* 'arraybuffer' or 'text' or '' or 'json' or 'blob' */ /** * Private methods @@ -186,7 +264,7 @@ function XMLHttpRequest(opts) { * @return boolean False if not allowed, otherwise true */ var isAllowedHttpHeader = function(header) { - return disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1); + return opts.disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1); }; /** @@ -199,52 +277,6 @@ 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. @@ -264,10 +296,120 @@ function XMLHttpRequest(opts) { return ab; } + /** + * Given the user-input (or Content-Type header value) of MIME type, + * Parse given string to retrieve mimeType and its encoding (defaults to utf8 if not exists) + * @param {string} contentType + */ + var parseContentType = function (contentType) { + const regex = /([a-zA-Z0-9!#$%&'*+.^_`|~-]+\/[a-zA-Z0-9!#$%&'*+.^_`|~-]+)(?:; charset=([a-zA-Z0-9-]+))?/; + + const matches = contentType.toLowerCase().match(regex); + + if (matches) { + const mimeType = matches[1]; + const charset = matches[2] || 'utf-8'; + + return { mimeType, charset }; + } else { + return { mimeType: "", charset: "utf-8" } + } + } + + /** + * 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. + */ + var handleError = function(error, status) { + dataMap.status = status || 0; + dataMap.statusText = error.message || ""; + dataMap.responseText = ""; + dataMap.responseXML = ""; + dataMap.responseURL = ""; + dataMap.response = Buffer.alloc(0); + errorFlag = true; + setState(self.DONE); + if (!settings.async) throw error; + }; + + /** + * Construct the correct form of response, given default content type + * + * The input is the response parameter which is a Buffer. + * When self.responseType is "", "text", + * the input is further refined to be: new TextDecoder(encoding).decode(response), + * encoding is defined either by `Content-Type` header or set through `xhr.overrideMimetype()`. + * When self.responseType is "json", + * the input is further refined to be: JSON.parse(response.toString('utf8')). + * When self.responseType is "arraybuffer", "blob", + * the input is further refined to be: checkAndShrinkBuffer(response). + * A special case is when self.responseType is "document", + * the decoded text will be passed to a parser function to create a DOM, or returns `null` + * + * @param {Buffer} response + */ + var createResponse = function(response, customContentType) { + dataMap.responseText = null; + dataMap.responseXML = null; + switch (self.responseType) { + case 'json': + dataMap.response = JSON.parse(response.toString('utf8')); + break; + case 'blob': + case 'arraybuffer': + // When self.responseType === 'arraybuffer', self.response is an ArrayBuffer. + // Get the correct sized ArrayBuffer. + dataMap.response = checkAndShrinkBuffer(response); + if (dataMap.responseType === 'blob' && typeof Blob === 'function') { + // Construct the Blob object that contains response. + dataMap.response = new Blob([self.response]); + } + break; + default: + try { + dataMap.responseText = opts.textDecoder.call(opts, response, customEncoding || parseContentType(String(customContentType)).charset); + } + catch (e) { + // fall back to utf8 ONLY if custom encoding is present + if (customEncoding) dataMap.responseText = response.toString('utf8'); + else dataMap.responseText = ""; + } + dataMap.response = self.responseText; + try { dataMap.responseXML = opts.xmlParser.call(opts, self.responseText); } + catch (e) { dataMap.responseXML = null; } + } + + // Special handling of self.responseType === 'document' + if (dataMap.responseType === 'document') { + dataMap.response = self.responseXML; + dataMap.responseText = null; + } + } + /** * Public methods */ + /** + * Acts as if the Content-Type header value for a response is mime. (It does not change the header.) + * Throws an error if state is LOADING or DONE. + * + * @param {string} mimeType - The MIME type to override with (e.g., "text/plain; charset=UTF-8"). + */ + assignProp(this, 'overrideMimeType', function(mimeType) { + if (arguments.length === 0) { + throw new TypeError("Failed to execute 'overrideMimeType' on 'XMLHttpRequest': 1 argument required, but only 0 present."); + } + + // check if state is LOADING or DONE + if (readyState === this.LOADING || readyState === this.DONE) { + throw new Error("INVALID_STATE_ERR: MimeType cannot be overridden when the state is LOADING or DONE."); + } + + // parse mimeType from given string and set custom charset + customEncoding = parseContentType(String(mimeType)).charset; + }); + /** * Open the connection. Currently supports local server requests. * @@ -277,8 +419,8 @@ function XMLHttpRequest(opts) { * @param string user Username for basic authentication (optional) * @param string password Password for basic authentication (optional) */ - this.open = function(method, url, async, user, password) { - this.abort(); + assignProp(this, 'open', function(method, url, async, user, password) { + abort(); errorFlag = false; abortedFlag = false; @@ -295,6 +437,11 @@ function XMLHttpRequest(opts) { "password": password || null }; + // check for sync + if (opts.syncPolicy === "warn" && !settings.async) { + console.warn("[Deprecation] Synchronous XMLHttpRequest is deprecated because of its detrimental effects to the end user's experience. For more information, see https://xhr.spec.whatwg.org/#sync-flag"); + } + // parse origin try { settings.origin = new URL(opts.origin); @@ -304,17 +451,7 @@ function XMLHttpRequest(opts) { } setState(this.OPENED); - }; - - /** - * Disables or enables isAllowedHttpHeader() check the request. Enabled by default. - * This does not conform to the W3C spec. - * - * @param boolean state Enable or disable header checking. - */ - this.setDisableHeaderCheck = function(state) { - disableHeaderCheck = state; - }; + }); /** * Sets a header for the request. @@ -323,8 +460,8 @@ function XMLHttpRequest(opts) { * @param string value Header value * @return boolean Header added */ - this.setRequestHeader = function(header, value) { - if (this.readyState != this.OPENED) { + assignProp(this, 'setRequestHeader', function(header, value) { + if (readyState != this.OPENED) { throw new Error("INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN"); } if (!isAllowedHttpHeader(header)) { @@ -336,7 +473,24 @@ function XMLHttpRequest(opts) { } headers[header] = value; return true; - }; + }); + + /** + * Gets a request header + * + * @deprecated + * @param string name Name of header to get + * @return string Returns the request header or empty string if not set + */ + assignProp(this, 'getRequestHeader', function(name) { + // @TODO Make this case insensitive + console.warn("`xhr.getRequestHeader()` is deprecated and will be removed in a future release. It’s non-standard and not part of the XHR spec."); + if (typeof name === "string" && headers[name]) { + return headers[name]; + } + + return ""; + }); /** * Gets a header from the server response. @@ -344,10 +498,10 @@ function XMLHttpRequest(opts) { * @param string header Name of header to get. * @return string Text of the header or null if it doesn't exist. */ - this.getResponseHeader = function(header) { + assignProp(this, 'getResponseHeader', function(header) { // in case of local request, headers are not present if (typeof header === "string" - && this.readyState > this.OPENED + && readyState > this.OPENED && response.headers[header.toLowerCase()] && !errorFlag && response @@ -357,16 +511,16 @@ function XMLHttpRequest(opts) { } return null; - }; + }); /** * Gets all the response headers. * * @return string A string with all response headers separated by CR+LF */ - this.getAllResponseHeaders = function() { + assignProp(this, 'getAllResponseHeaders', function() { // in case of local request, headers are not present - if (this.readyState < this.HEADERS_RECEIVED || errorFlag || !response || !response.headers) { + if (readyState < this.HEADERS_RECEIVED || errorFlag || !response || !response.headers) { return ""; } var result = ""; @@ -378,22 +532,7 @@ function XMLHttpRequest(opts) { } } return result.slice(0, -2); - }; - - /** - * Gets a request header - * - * @param string name Name of header to get - * @return string Returns the request header or empty string if not set - */ - this.getRequestHeader = function(name) { - // @TODO Make this case insensitive - if (typeof name === "string" && headers[name]) { - return headers[name]; - } - - return ""; - }; + }); /** * Convert from Data URI to Buffer @@ -410,10 +549,21 @@ function XMLHttpRequest(opts) { if (parts.length < 2) throw "Invalid URL"; + var dataHeaders = parts[0].split(";"); + + var base64 = false, charset; + // 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"; - }); + // also get charset encoding of data URI (from FIRST found only) + for (var i = 1; i < dataHeaders.length; ++i) { + if (base64 && charset) break; + var header = dataHeaders[i]; + + if (!base64) base64 = header.toLowerCase() === "base64"; + if (!charset && header.startsWith("charset=")) { + charset = header.slice(8).toLowerCase(); + } + } var responseData, inputData = decodeURIComponent(parts[1]); @@ -426,10 +576,16 @@ function XMLHttpRequest(opts) { 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; + return { + data: responseData, + charset: charset || "utf-8" + } } else { - return Buffer.from(inputData); + return { + data: Buffer.from(inputData), + charset: charset || "utf-8" + } } } @@ -438,8 +594,8 @@ function XMLHttpRequest(opts) { * * @param string data Optional data to send as request body. */ - this.send = function(data) { - if (this.readyState != this.OPENED) { + assignProp(this, 'send', function(data) { + if (readyState != this.OPENED) { throw new Error("INVALID_STATE_ERR: connection must be opened before send() is called"); } @@ -447,6 +603,10 @@ function XMLHttpRequest(opts) { throw new Error("INVALID_STATE_ERR: send has already been called"); } + if (opts.syncPolicy === "disabled" && !settings.async) { + throw new Error("Synchronous requests are disabled for this instance."); + } + var isSsl = false, isLocal = false, isDataUri = false; var url; try { @@ -460,7 +620,7 @@ function XMLHttpRequest(opts) { } catch (e) { // URL parsing throws TypeError, here we only want to take its message - self.handleError(new Error(e.message)); + handleError(new Error(e.message)); return; } var host; @@ -493,19 +653,20 @@ function XMLHttpRequest(opts) { // or data from Data URI (data:) if (isLocal) { if (isDataUri) try { - self.status = 200; - self.responseURL = settings.url; - self.createFileOrSyncResponse(bufferFromDataUri(url)); + dataMap.status = 200; + dataMap.responseURL = settings.url; + var uriData = bufferFromDataUri(url); + createResponse(uriData.data, "text/plain; charset=" + uriData.charset); setState(self.DONE); return; } catch (e) { - self.handleError(new Error("Invalid data URI")); + handleError(new Error("Invalid data URI")); return; } if (!opts.allowFileSystemResources) { - self.handleError(new Error("Not allowed to load local resource: " + url.href)); + handleError(new Error("Not allowed to load local resource: " + url.href)); return; } @@ -516,25 +677,25 @@ function XMLHttpRequest(opts) { if (settings.async) { fs.readFile(unescape(url.pathname), function(error, data) { if (error) { - self.handleError(error, error.errno || -1); + handleError(error, error.errno || -1); } else { - self.status = 200; - self.responseURL = settings.url; + dataMap.status = 200; + dataMap.responseURL = settings.url; // Use self.responseType to create the correct self.responseType, self.response. - self.createFileOrSyncResponse(data); + createResponse(data, ""); setState(self.DONE); } }); } else { try { - this.status = 200; + dataMap.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); + dataMap.responseURL = settings.url; + createResponse(syncData, ""); setState(self.DONE); } catch(e) { - this.handleError(e, e.errno || -1); + handleError(e, e.errno || -1); } } @@ -598,7 +759,7 @@ function XMLHttpRequest(opts) { sendFlag = true; // As per spec, this is called here for historical reasons. - self.dispatchEvent("readystatechange"); + dispatchEvent("readystatechange"); // Handler for the response var responseHandler = function(resp) { @@ -614,7 +775,7 @@ function XMLHttpRequest(opts) { // end the response resp.destroy(); if (redirectCount > maxRedirects) { - self.handleError(new Error("Too many redirects")); + handleError(new Error("Too many redirects")); return; } // Change URL to the redirect location @@ -626,7 +787,7 @@ function XMLHttpRequest(opts) { settings.url = url.href; } catch (e) { - self.handleError(new Error("Unsafe redirect")); + handleError(new Error("Unsafe redirect")); return; } // change request options again to match with new redirect protocol @@ -665,30 +826,12 @@ function XMLHttpRequest(opts) { 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; + dataMap.status = response.statusCode; response.on('data', function(chunk) { // Make sure there's some data if (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); - } + buffers.push(chunk); } // Don't emit state changes if the connection has been aborted. if (sendFlag) { @@ -698,20 +841,20 @@ function XMLHttpRequest(opts) { response.on('end', function() { if (sendFlag) { - // The sendFlag needs to be set before setState is called. Otherwise if we are chaining callbacks + // 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; + createResponse(Buffer.concat(buffers), response.headers['content-type'] || ""); + dataMap.statusText = this.statusMessage; + dataMap.responseURL = settings.url; // Discard the 'end' event if the connection has been aborted setState(self.DONE); } }.bind(response)); response.on('error', function(error) { - self.handleError(error); + handleError(error); }.bind(response)); } @@ -721,11 +864,11 @@ function XMLHttpRequest(opts) { // don't fail the xhr request, attempt again. if (request.reusedSocket && error.code === 'ECONNRESET') return doRequest(options, responseHandler).on('error', errorHandler); - self.handleError(error); + handleError(error); } var createRequest = function (opt) { - opt = Object.assign({}, opt); + opt = Object.assign(Object.create(null), opt); if (isSsl) Object.assign(opt, sslOptions); request = doRequest(opt, responseHandler).on('error', errorHandler); @@ -747,120 +890,129 @@ function XMLHttpRequest(opts) { // Create the request createRequest(options); - self.dispatchEvent("loadstart"); + dispatchEvent("loadstart"); } else { // Synchronous - // Create a temporary file for communication with the other Node process - var contentFile = ".node-xmlhttprequest-content-" + process.pid; - var syncFile = ".node-xmlhttprequest-sync-" + process.pid; - fs.writeFileSync(syncFile, "", "utf8"); - // The async request the other Node process executes - var execString = "'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 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]); - while(fs.existsSync(syncFile)) { - // Wait while the sync file is empty + try { + // Create a temporary file for communication with the other Node process + var tmpDir = os.tmpdir(); + var syncResponse; + var contentFile = path.join(tmpDir, ".node-xmlhttprequest-content-" + process.pid); + var syncFile = path.join(tmpDir, ".node-xmlhttprequest-sync-" + process.pid); + fs.writeFileSync(syncFile, "", "utf8"); + // The async request the other Node process executes + 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 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(Object.create(null), 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 }}), 'utf8');" + + " fs.writeFileSync('" + contentFile + ".bin', responseData);" + + " 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]); + while(fs.existsSync(syncFile)) { + // Wait while the sync file is empty + } + syncResponse = fs.readFileSync(contentFile, 'utf8'); + // Kill the child process once the file has data + syncProc.stdin.end(); + // Remove the temporary file + fs.unlinkSync(contentFile); + } + catch (e) { + handleError(new Error("Synchronous operation aborted: Unable to access the OS temporary directory for read/write operations.")); } - self.responseText = fs.readFileSync(contentFile, 'utf8'); - // Kill the child process once the file has data - syncProc.stdin.end(); - // Remove the temporary file - fs.unlinkSync(contentFile); - if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR(-REDIRECT){0,1}:/)) { + if (syncResponse.match(/^NODE-XMLHTTPREQUEST-ERROR(-REDIRECT){0,1}:/)) { // If the file returned an error, handle it - if (self.responseText.startsWith('NODE-XMLHTTPREQUEST-ERROR-REDIRECT')) { - self.handleError(new Error(self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR-REDIRECT: /, ""))); + if (syncResponse.startsWith('NODE-XMLHTTPREQUEST-ERROR-REDIRECT')) { + handleError(new Error(syncResponse.replace(/^NODE-XMLHTTPREQUEST-ERROR-REDIRECT: /, ""))); } else { - var errorObj = JSON.parse(self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, "")); - self.handleError(errorObj, 503); + var errorObj = JSON.parse(syncResponse.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, "")); + handleError(errorObj, 503); } - } else { + } else try { // If the file returned okay, parse its data and move to the DONE state - 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); + const resp = JSON.parse(syncResponse); + dataMap.status = resp.data.statusCode; + dataMap.statusText = resp.data.statusText; + dataMap.responseURL = resp.data.url; + dataMap.response = fs.readFileSync(contentFile + ".bin"); + fs.unlinkSync(contentFile + ".bin"); // Use self.responseType to create the correct self.responseType, self.response, self.responseXML. - self.createFileOrSyncResponse(self.response); + createResponse(self.response, resp.data.headers["content-type"] || ""); // Set up response correctly. response = { statusCode: self.status, @@ -868,87 +1020,79 @@ function XMLHttpRequest(opts) { }; setState(self.DONE); } + catch (e) { + handleError(new Error("Synchronous operation aborted: Unable to access the OS temporary directory for read/write operations.")); + } } - }; - - /** - * 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, 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; - }; + }); /** * Aborts a request. */ - this.abort = function() { + var abort = function() { if (request) { request.abort(); request = null; } - headers = Object.assign({}, defaultHeaders); - this.responseText = ""; - this.responseXML = ""; - this.response = Buffer.alloc(0); + headers = Object.assign(Object.create(null), defaultHeaders); + dataMap.responseText = ""; + dataMap.responseXML = ""; + dataMap.response = Buffer.alloc(0); errorFlag = abortedFlag = true - if (this.readyState !== this.UNSENT - && (this.readyState !== this.OPENED || sendFlag) - && this.readyState !== this.DONE) { + if (readyState !== self.UNSENT + && (readyState !== self.OPENED || sendFlag) + && readyState !== self.DONE) { sendFlag = false; - setState(this.DONE); + setState(self.DONE); } - this.readyState = this.UNSENT; + readyState = self.UNSENT; }; + /** + * Aborts a request. + */ + assignProp(this, 'abort', abort); + /** * Adds an event listener. Preferred method of binding to events. */ - this.addEventListener = function(event, callback) { + assignProp(this, 'addEventListener', function(event, callback) { if (!(event in listeners)) { listeners[event] = []; } // Currently allows duplicate callbacks. Should it? listeners[event].push(callback); - }; + }); /** * Remove an event callback that has already been bound. * Only works on the matching funciton, cannot be a copy. */ - this.removeEventListener = function(event, callback) { + assignProp(this, 'removeEventListener', function(event, callback) { if (event in listeners) { // Filter will return a new array with the callback removed listeners[event] = listeners[event].filter(function(ev) { return ev !== callback; }); } - }; + }); /** * Dispatch any events, including both "on" methods and events attached using addEventListener. */ - this.dispatchEvent = function (event) { + var dispatchEvent = function (event) { let argument = { type: event }; if (typeof self["on" + event] === "function") { - if (this.readyState === this.DONE && settings.async) + if (readyState === self.DONE && settings.async) setTimeout(function() { self["on" + event](argument) }, 0) else self["on" + event](argument) } if (event in listeners) { for (let i = 0, len = listeners[event].length; i < len; i++) { - if (this.readyState === this.DONE) + if (readyState === self.DONE) setTimeout(function() { listeners[event][i].call(self, argument) }, 0) else listeners[event][i].call(self, argument) @@ -957,74 +1101,9 @@ function XMLHttpRequest(opts) { }; /** - * 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 + * Dispatch any events, including both "on" methods and events attached using addEventListener. */ - 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; - } - } + assignProp(this, 'dispatchEvent', dispatchEvent); /** * Changes readyState and calls onreadystatechange. @@ -1032,16 +1111,16 @@ function XMLHttpRequest(opts) { * @param int state New state */ var setState = function(state) { - if ((self.readyState === state) || (self.readyState === self.UNSENT && abortedFlag)) + if ((readyState === state) || (readyState === self.UNSENT && abortedFlag)) return - self.readyState = state; + readyState = state; - if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) { - self.dispatchEvent("readystatechange"); + if (settings.async || readyState < self.OPENED || readyState === self.DONE) { + dispatchEvent("readystatechange"); } - if (self.readyState === self.DONE) { + if (readyState === self.DONE) { let fire if (abortedFlag) @@ -1051,10 +1130,10 @@ function XMLHttpRequest(opts) { else fire = "load" - self.dispatchEvent(fire) + dispatchEvent(fire) // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) - self.dispatchEvent("loadend"); + dispatchEvent("loadend"); } }; }; diff --git a/package.json b/package.json index 8db9dfb..926be7f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "bugs": "http://github.com/mjwwit/node-XMLHttpRequest/issues", "engines": { - "node": ">=12.0.0" + "node": ">=13.0.0" }, "scripts": { "test": "cd ./tests && node run-test.js" diff --git a/tests/server.js b/tests/server.js index e76752b..28fe380 100644 --- a/tests/server.js +++ b/tests/server.js @@ -1,6 +1,26 @@ 'use strict'; var http = require("http"); +var bufferBody = Buffer.from([ + 0x48, // H + 0xE9, // é + 0x6C, // l + 0x6C, // l + 0x6F, // o + 0x20, // + 0x77, // w + 0xF8, // ø + 0x72, // r + 0x6C, // l + 0x64, // d + 0x20, // + 0x6E, // n + 0x61, // a + 0xEF, // ï + 0x76, // v + 0x65 // e +]); + var server = http.createServer(function (req, res) { switch (req.url) { case "/": { @@ -32,10 +52,23 @@ var server = http.createServer(function (req, res) { return; case "/binary2": const ta = new Float32Array([1, 5, 6, 7]); - const buf = Buffer.from(ta.buffer); - const str = buf.toString('binary'); + const buf = Buffer.from(ta); res.writeHead(200, {"Content-Type": "application/octet-stream"}) - res.end(str); + res.end(buf); + return; + case "/latin1": + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=ISO-8859-1', + 'Content-Length': bufferBody.length + }); + res.end(bufferBody); + return; + case "/latin1-invalid": + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=lorem_ipsum', + 'Content-Length': bufferBody.length + }); + res.end(bufferBody); return; default: if (req.url.startsWith('/redirectingResource/')) { diff --git a/tests/test-constants.js b/tests/test-constants.js index 57e1780..0cd7ec4 100644 --- a/tests/test-constants.js +++ b/tests/test-constants.js @@ -3,6 +3,12 @@ var assert = require("assert") , xhr = new XMLHttpRequest(); // Test constant values +assert.equal(0, XMLHttpRequest.UNSENT); +assert.equal(1, XMLHttpRequest.OPENED); +assert.equal(2, XMLHttpRequest.HEADERS_RECEIVED); +assert.equal(3, XMLHttpRequest.LOADING); +assert.equal(4, XMLHttpRequest.DONE); + assert.equal(0, xhr.UNSENT); assert.equal(1, xhr.OPENED); assert.equal(2, xhr.HEADERS_RECEIVED); diff --git a/tests/test-data-uri.js b/tests/test-data-uri.js index c94dd23..e7c3314 100644 --- a/tests/test-data-uri.js +++ b/tests/test-data-uri.js @@ -1,8 +1,5 @@ var assert = require("assert") - , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest - , xhr; - -xhr = new XMLHttpRequest(); + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; // define test data var tests = [ @@ -63,7 +60,7 @@ var tests_passed = 0; var runAsyncTest = function (test) { console.log(" ASYNC"); - xhr = new XMLHttpRequest; + var xhr = new XMLHttpRequest; xhr.open("get", test.data); xhr.onreadystatechange = function () { if (this.readyState === 4) { @@ -75,7 +72,7 @@ var runAsyncTest = function (test) { assert.equal(xhr.status, 200); assert.equal(xhr.responseText, test.output); } - console.log(" --> SUCESS"); + console.log(" --> SUCCESS"); ++tests_passed; } } @@ -85,7 +82,7 @@ var runAsyncTest = function (test) { var runSyncTest = function (test) { console.log(" SYNC"); - xhr = new XMLHttpRequest; + var xhr = new XMLHttpRequest; xhr.open("get", test.data, false); try { xhr.send(); @@ -108,7 +105,13 @@ var startTest = function () { let test = tests[i]; if (!test) { - console.log("Done:", tests_passed === tests.length * 2 ? "PASS" : "FAILED"); + console.log(tests_passed, "/", tests.length * 2, "tests passed"); + if (tests_passed === tests.length * 2) + console.log("Done: PASS"); + else { + console.error("Done: FAILED"); + throw ""; + } return; } diff --git a/tests/test-headers.js b/tests/test-headers.js index e22e5f9..c957103 100644 --- a/tests/test-headers.js +++ b/tests/test-headers.js @@ -5,14 +5,23 @@ var assert = require("assert") // Test server var server = http.createServer(function (req, res) { - // Test setRequestHeader - assert.equal("Foobar", req.headers["x-test"]); - // Test non-conforming allowed header - 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"]); + switch (req.url) { + case "/allow": + // Test disabling header check + assert.equal("http://github.com", req.headers["referer"]); + console.log("No header check: PASSED"); + break; + default: + // Test setRequestHeader + assert.equal("Foobar", req.headers["x-test"]); + // Test non-conforming allowed header + assert.equal("node-XMLHttpRequest-test", req.headers["user-agent"]); + // Test case insensitive header was set + assert.equal("text/plain", req.headers["content-type"]); + // Test forbidden header + assert.equal(null, req.headers["referer"]); + console.log("Strict header check: PASSED"); + } var body = "Hello World"; res.writeHead(200, { @@ -48,7 +57,7 @@ xhr.onreadystatechange = function() { assert.equal("", this.getAllResponseHeaders()); assert.equal(null, this.getResponseHeader("Connection")); - console.log("done"); + console.log("Response headers check: PASSED"); } }; @@ -58,24 +67,28 @@ try { var body = "Hello World"; // Valid header xhr.setRequestHeader("X-Test", "Foobar"); - // Invalid header + // Invalid header Content-Length xhr.setRequestHeader("Content-Length", Buffer.byteLength(body)); + // Invalid header Referer + xhr.setRequestHeader("Referer", "http://github.com"); // 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 - assert.equal("", xhr.getRequestHeader("Content-Length")); + xhr.send(body); +} catch(e) { + console.error("ERROR: Exception raised", e); + throw e; +} +try { // Test allowing all headers - xhr.setDisableHeaderCheck(true); + xhr = new XMLHttpRequest({ disableHeaderCheck: true }); + xhr.open("POST", "http://localhost:8000/allow"); xhr.setRequestHeader("Referer", "http://github.com"); - assert.equal("http://github.com", xhr.getRequestHeader("Referer")); - - xhr.send(body); -} catch(e) { + xhr.send(); +} +catch (e) { console.error("ERROR: Exception raised", e); throw e; } diff --git a/tests/test-keepalive.js b/tests/test-keepalive.js index a5b09e7..f7d22bd 100644 --- a/tests/test-keepalive.js +++ b/tests/test-keepalive.js @@ -34,4 +34,4 @@ var interval = setInterval(function sendRequest() { } } xhr.send(); -}, 200); \ No newline at end of file +}, 200); diff --git a/tests/test-max-redirects.js b/tests/test-max-redirects.js index 9938528..1afd3ec 100644 --- a/tests/test-max-redirects.js +++ b/tests/test-max-redirects.js @@ -11,7 +11,7 @@ var runTest = function () { xhr.open("GET", "http://localhost:8888/redirectingResource/10", false); xhr.onreadystatechange = function() { if (xhr.readyState === 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); console.log("safe redirects count: done"); } diff --git a/tests/test-mimetype.js b/tests/test-mimetype.js new file mode 100644 index 0000000..5e65d21 --- /dev/null +++ b/tests/test-mimetype.js @@ -0,0 +1,168 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , spawn = require('child_process').spawn + , serverProcess; + +const body = Buffer.from([ + 0x48, // H + 0xE9, // é + 0x6C, // l + 0x6C, // l + 0x6F, // o + 0x20, // + 0x77, // w + 0xF8, // ø + 0x72, // r + 0x6C, // l + 0x64, // d + 0x20, // + 0x6E, // n + 0x61, // a + 0xEF, // ï + 0x76, // v + 0x65 // e +]); + +var base64Str = function (charset) { + return "data:text/plain;base64;charset=" + charset + "," + body.toString('base64'); +} + +// specify custom decoder to work on older node versions +var decodeTextFromBuffer = function (buf, enc) { + if (enc == "iso-8859-1") return buf.toString("latin1"); + return new TextDecoder(enc).decode(buf); +} + +var plainStr = decodeTextFromBuffer(body, "iso-8859-1"); +var plainStrUTF8 = decodeTextFromBuffer(body, "utf-8"); + +var createXHRInstance = function (opts) { + return new XMLHttpRequest(Object.assign({ textDecoder: null }, opts)); +} + +// spawn a server +serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +setTimeout(function () { + try { + runTest(); + console.log('PASSED'); + } catch (e) { + console.log('FAILED'); + serverProcess.kill('SIGINT'); + throw e; + } finally { + + } +}, 100); + +var tests = [ + { + name: "XHR with default latin-1 encoding", + endpoint: "http://localhost:8888/latin1", + expected: plainStr + }, + { + name: "XHR with overrideMimeType charset=latin-1", + endpoint: "http://localhost:8888/latin1-invalid", + override: "text/plain; charset=ISO-8859-1", + expected: plainStr + }, + { + name: "XHR with wrong charset (utf-8 expected, actual is latin-1)", + endpoint: "http://localhost:8888/latin1-invalid", + expected: '' + }, + { + name: "XHR with wrong overriden charset (utf-8 expected, actual is latin-1)", + endpoint: "http://localhost:8888/latin1-invalid", + override: "text/plain; charset=lorem_ipsum", + expected: plainStrUTF8 + }, + { + name: "XHR on data URI with Latin-1 and charset specified", + endpoint: base64Str("ISO-8859-1"), + expected: plainStr + }, + { + name: "XHR on data URI with overrideMimeType to Latin-1", + endpoint: base64Str("UTF-8"), + override: "text/plain; charset=ISO-8859-1", + expected: plainStr + }, + { + name: "XHR on data URI with wrong default charset (utf-8 vs latin-1)", + endpoint: base64Str("lorem_ipsum"), + expected: '' + }, + { + name: "XHR with wrong overriden charset and Data URI (utf-8 expected, actual is latin-1)", + endpoint: base64Str("iso-8859-1"), + override: "text/plain; charset=lorem_ipsum", + expected: plainStrUTF8 + } +]; + +var tests_passed = 0; + +var total_tests = tests.length * 2; + +var runSyncTest = function (i) { + var test = tests[i]; + var index = i + 1; + try { + var xhr = createXHRInstance(); + console.log("Test " + index + ": [SYNC] " + test.name); + xhr.open("GET", test.endpoint, false); + if (test.override) xhr.overrideMimeType(test.override); + xhr.send(); + assert.equal(xhr.responseText, test.expected); + console.log("Test " + index + ": PASSED"); + ++tests_passed; + } catch (e) { + console.log("Test " + index + ": FAILED with exception", e); + } +} + +var runAsyncTest = function (i) { + if (i >= tests.length) { + serverProcess.kill('SIGINT'); + if (tests_passed === total_tests) return console.log("ALL PASSED"); + else { + console.error("FAILED: Only " + tests_passed + " / " + total_tests + " tests passed"); + throw ""; + }; + } + var test = tests[i]; + var index = i + tests.length + 1; + try { + var xhr = createXHRInstance(); + console.log("Test " + index + ": [ASYNC] " + test.name); + xhr.open("GET", test.endpoint); + if (test.override) xhr.overrideMimeType(test.override); + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) try { + assert.equal(xhr.responseText, test.expected); + console.log("Test " + index + ": PASSED"); + ++tests_passed; + runAsyncTest(i + 1); + } + catch (e) { + console.log("Test " + index + ": FAILED with exception", e); + runAsyncTest(i + 1); + } + } + xhr.send(); + } catch (e) { + console.log("Test " + index + ": FAILED with exception", e); + runAsyncTest(i + 1); + } +} + +var runTest = function () { + for (var i = 0; i < tests.length; i++) { + runSyncTest(i); + } + + runAsyncTest(0); +} diff --git a/tests/test-pollution.js b/tests/test-pollution.js new file mode 100644 index 0000000..9fbc516 --- /dev/null +++ b/tests/test-pollution.js @@ -0,0 +1,61 @@ +// Main purpose of this test is to ensure that XHR options and exposed methods cannot be prototype-polluted + +var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest, + spawn = require("child_process").spawn, + assert = require("assert"), + xhr, + objectProto = Object.getPrototypeOf({}); + +// spawn a server +serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +var polluteFunc = function (buf, enc) { + return "Polluted!"; +} + +var runTest = function () { + // most naive pollution + objectProto.textDecoder = polluteFunc; + + xhr = new XMLHttpRequest(); + xhr.open("GET", "http://localhost:8888", false); + xhr.send(); + assert.equal("Hello World", xhr.responseText); + console.log("Naive pollution: PASSED"); + + delete objectProto.textDecoder; + + // pollute with getter/setter + Object.defineProperty(objectProto, 'textDecoder', { + get: function () { return polluteFunc; }, + set: function (value) {} + }); + + xhr = new XMLHttpRequest(); + xhr.open("GET", "http://localhost:8888", false); + xhr.send(); + assert.equal("Hello World", xhr.responseText); + console.log("Getter/Setter pollution: PASSED"); + + // pollute xhr properties + Object.defineProperty(objectProto, 'responseText', { + get: function () { return "Polluted!"; }, + set: function (value) {} + }); + + xhr = new XMLHttpRequest(); + xhr.open("GET", "http://localhost:8888", false); + xhr.send(); + assert.equal("Hello World", xhr.responseText); + console.log("Pollute xhr.responseText: PASSED"); +} + +try { + runTest(); +} +catch (e) { + throw e; +} +finally { + serverProcess.kill('SIGINT'); +} diff --git a/tests/test-redirect-301.js b/tests/test-redirect-301.js index 91ec4cf..c116533 100644 --- a/tests/test-redirect-301.js +++ b/tests/test-redirect-301.js @@ -26,7 +26,7 @@ var server = http.createServer(function (req, res) { xhr.onreadystatechange = function() { if (this.readyState === 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); console.log("done"); } diff --git a/tests/test-redirect-302.js b/tests/test-redirect-302.js index 802e948..ef32808 100644 --- a/tests/test-redirect-302.js +++ b/tests/test-redirect-302.js @@ -26,7 +26,7 @@ var server = http.createServer(function (req, res) { xhr.onreadystatechange = function() { if (this.readyState === 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); console.log("done"); } diff --git a/tests/test-redirect-303.js b/tests/test-redirect-303.js index 4d51962..717aa42 100644 --- a/tests/test-redirect-303.js +++ b/tests/test-redirect-303.js @@ -26,7 +26,7 @@ var server = http.createServer(function (req, res) { xhr.onreadystatechange = function() { if (this.readyState === 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); console.log("done"); } diff --git a/tests/test-redirect-307.js b/tests/test-redirect-307.js index 6e8cb9f..04ff971 100644 --- a/tests/test-redirect-307.js +++ b/tests/test-redirect-307.js @@ -28,7 +28,7 @@ var server = http.createServer(function (req, res) { xhr.onreadystatechange = function() { if (this.readyState === 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); console.log("done"); } diff --git a/tests/test-redirect-308.js b/tests/test-redirect-308.js index d517f68..6de9f7b 100644 --- a/tests/test-redirect-308.js +++ b/tests/test-redirect-308.js @@ -26,7 +26,7 @@ var server = http.createServer(function (req, res) { xhr.onreadystatechange = function() { if (this.readyState === 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); console.log("done"); } diff --git a/tests/test-sync-flag.js b/tests/test-sync-flag.js new file mode 100644 index 0000000..8f9af5c --- /dev/null +++ b/tests/test-sync-flag.js @@ -0,0 +1,107 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , spawn = require("child_process").spawn + , serverProcess + , process = require("process"); + +// spawn a server +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); + +/** + * stage = 0 // idle + * stage = 1 // expect warning to check + * stage = 2 // available but does not expect warning + */ +var stage = 0; +// warning catch +let oldWarn = console.warn; +console.warn = function (warning) { + if (stage > 0) { + if (stage === 1) { + assert.equal(warning, "[Deprecation] Synchronous XMLHttpRequest is deprecated because of its detrimental effects to the end user's experience. For more information, see https://xhr.spec.whatwg.org/#sync-flag"); + console.log("Correct warning caught."); + } + else if (stage === 2) { + throw "Does not expect warning, caught " + JSON.stringify(warning); + } + } + + return oldWarn.call(this, warning); +} + +var runTest = function () { + // xhr with no syncPolicy (default = warn) + try { + console.log("Testing 1: XHR with no syncPolicy (default = warn)"); + var xhr = new XMLHttpRequest(); + stage = 1; + xhr.open("GET", "http://localhost:8888/text", false); + stage = 0; + xhr.send(); + assert.equal(xhr.responseText, "Hello world!"); + console.log("Test 1: PASSED"); + } catch(e) { + console.log("ERROR: Exception raised", e); + throw e; + } + + // xhr with syncPolicy = warn + try { + console.log("Testing 2: XHR with syncPolicy = warn"); + var xhr = new XMLHttpRequest({ syncPolicy: "warn" }); + stage = 1; + xhr.open("GET", "http://localhost:8888/text", false); + stage = 0; + xhr.send(); + assert.equal(xhr.responseText, "Hello world!"); + console.log("Test 2: PASSED"); + } catch(e) { + console.log("ERROR: Exception raised", e); + throw e; + } + + // xhr with syncPolicy = enabled + try { + console.log("Testing 3: XHR with syncPolicy = enabled"); + var xhr = new XMLHttpRequest({ syncPolicy: "enabled" }); + stage = 2; + xhr.open("GET", "http://localhost:8888/text", false); + stage = 0; + xhr.send(); + assert.equal(xhr.responseText, "Hello world!"); + console.log("Test 3: PASSED"); + } catch(e) { + console.log("ERROR: Exception raised", e); + throw e; + } + + // xhr with syncPolicy = disabled + var errored = false; + try { + console.log("Testing 4: XHR with syncPolicy = disabled"); + var xhr = new XMLHttpRequest({ syncPolicy: "disabled" }); + stage = 2; + xhr.open("GET", "http://localhost:8888/text", false); + stage = 0; + xhr.send(); + } catch(e) { + errored = true; + assert.equal(e.message, "Synchronous requests are disabled for this instance."); + console.log("Correct error message.") + console.log("Test 4: PASSED"); + } + + if (!errored) throw "Test 4 expects an error."; +} diff --git a/tests/test-sync-response.js b/tests/test-sync-response.js index 734fe01..a9ae473 100644 --- a/tests/test-sync-response.js +++ b/tests/test-sync-response.js @@ -30,21 +30,6 @@ setTimeout(function () { } }, 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 @@ -106,8 +91,8 @@ function runTest() { xhr.onreadystatechange = function () { if (xhr.readyState === 4) { // xhr.response is an ArrayBuffer - var binaryStr = Buffer.from(xhr.response).toString('binary'); - var f32 = stringToFloat32Array(binaryStr); + var binary = Buffer.from(xhr.response); + var f32 = new Float32Array(binary); log('/binary2', f32); var answer = new Float32Array([1, 5, 6, 7]); assert.equal(isEqual(f32, answer), true); From c4168adb718afd7606ca1204c03da6ab4e8e0a4a Mon Sep 17 00:00:00 2001 From: Michael de Wit Date: Sat, 17 May 2025 09:53:36 +0200 Subject: [PATCH 40/40] chore: bump version to 4.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 926be7f..3bcf394 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "3.1.0", + "version": "4.0.0", "author": { "name": "Michael de Wit" },