├── test ├── fixtures │ ├── hello.txt │ ├── hello.json │ └── xhr2.png ├── src │ ├── helpers │ │ ├── browser_mocha_setup.coffee │ │ ├── browser_mocha_runner.coffee │ │ ├── setup.coffee │ │ └── xhr_server.coffee │ ├── responseurl_test.coffee │ ├── status_test.coffee │ ├── redirect_test.coffee │ ├── event_target_test.coffee │ ├── response_type_test.coffee │ ├── xhr_test.coffee │ ├── send_test.coffee │ ├── nodejs_set_test.coffee │ ├── headers_test.coffee │ └── events_test.coffee └── html │ └── browser_test.html ├── lib └── browser.js ├── .travis.yml ├── .vimrc ├── .gitignore ├── .npmignore ├── src ├── errors.coffee ├── progress_event.coffee ├── 000-xml_http_request_event_target.coffee ├── xml_http_request_upload.coffee └── 001-xml_http_request.coffee ├── LICENSE.txt ├── CONTRIBUTING.md ├── package.json ├── README.md └── Cakefile /test/fixtures/hello.txt: -------------------------------------------------------------------------------- 1 | Hello world! 2 | -------------------------------------------------------------------------------- /lib/browser.js: -------------------------------------------------------------------------------- 1 | module.exports = XMLHttpRequest; 2 | -------------------------------------------------------------------------------- /test/fixtures/hello.json: -------------------------------------------------------------------------------- 1 | {"hello": "world", "answer": 42} 2 | -------------------------------------------------------------------------------- /test/fixtures/xhr2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/node-xhr2/HEAD/test/fixtures/xhr2.png -------------------------------------------------------------------------------- /test/src/helpers/browser_mocha_setup.coffee: -------------------------------------------------------------------------------- 1 | mocha.setup ui: 'bdd', slow: 150, timeout: 1000, bail: false 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: focal 2 | language: node_js 3 | os: linux 4 | node_js: 5 | - "10" 6 | - "12" 7 | - "14" 8 | - "15" 9 | -------------------------------------------------------------------------------- /.vimrc: -------------------------------------------------------------------------------- 1 | " Indentation settings for the project: 2-space indentation, no tabs. 2 | set tabstop=2 3 | set softtabstop=2 4 | set shiftwidth=2 5 | set expandtab 6 | 7 | -------------------------------------------------------------------------------- /test/src/helpers/browser_mocha_runner.coffee: -------------------------------------------------------------------------------- 1 | window.addEventListener 'load', -> 2 | runner = null 3 | runner = mocha.run -> 4 | return if runner is null # Synchronous tests may call this spuriously. 5 | failures = runner.failures || 0 6 | total = runner.total || 0 7 | image = new Image() 8 | image.src = "/diediedie?failed=#{failures}&total=#{total}"; 9 | image.onload = -> 10 | null 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Vim. 2 | *.swp 3 | 4 | # OSX 5 | .DS_Store 6 | 7 | # Npm modules. 8 | node_modules 9 | 10 | # Vendored javascript modules. 11 | test/vendor 12 | 13 | # Build output. 14 | lib/xhr2.js 15 | test/js 16 | tmp/*.js 17 | 18 | # Documentation output. 19 | doc/*.html 20 | doc/assets 21 | doc/classes 22 | doc/files 23 | 24 | # Node packaging output. 25 | xhr2-*.tgz 26 | 27 | # Potentially sensitive keys used during testing. 28 | test/ssl 29 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Vim. 2 | *.swp 3 | .vimrc 4 | 5 | # OSX 6 | .DS_Store 7 | 8 | # Git. 9 | .git 10 | 11 | # Travis. 12 | .travis.yml 13 | 14 | # Npm modules. 15 | node_modules 16 | 17 | # Vendored javascript modules. 18 | test/vendor 19 | 20 | # Test build output. 21 | test/js 22 | tmp/*.js 23 | 24 | # Documentation output. 25 | doc/*.html 26 | doc/assets 27 | doc/classes 28 | doc/files 29 | 30 | # Potentially sensitive credentials and keys used during testing. 31 | test/ssl 32 | -------------------------------------------------------------------------------- /test/src/responseurl_test.coffee: -------------------------------------------------------------------------------- 1 | describe 'XMLHttpRequest', -> 2 | describe '#responseURL', -> 3 | beforeEach -> 4 | @xhr = new XMLHttpRequest 5 | 6 | it 'provies the URL of the response', (done) -> 7 | @xhr.open 'GET', 'http://localhost:8912/_/method' 8 | @xhr.onload = => 9 | expect(@xhr.responseURL).to.equal("http://localhost:8912/_/method") 10 | done() 11 | @xhr.send() 12 | 13 | it 'ignores the hash fragment', (done) -> 14 | @xhr.open 'GET', 'http://localhost:8912/_/method#foo' 15 | @xhr.onload = => 16 | expect(@xhr.responseURL).to.equal("http://localhost:8912/_/method") 17 | done() 18 | @xhr.send() 19 | -------------------------------------------------------------------------------- /src/errors.coffee: -------------------------------------------------------------------------------- 1 | # This file defines the custom errors used in the XMLHttpRequest specification. 2 | 3 | # Thrown if the XHR security policy is violated. 4 | class SecurityError extends Error 5 | # @private 6 | constructor: -> super() 7 | 8 | # Thrown if the XHR security policy is violated. 9 | XMLHttpRequest.SecurityError = SecurityError 10 | 11 | 12 | # Usually thrown if the XHR is in the wrong readyState for an operation. 13 | class InvalidStateError extends Error 14 | # @private 15 | constructor: -> super() 16 | 17 | # Usually thrown if the XHR is in the wrong readyState for an operation. 18 | class InvalidStateError extends Error 19 | XMLHttpRequest.InvalidStateError = InvalidStateError 20 | 21 | # Thrown if there is a problem with the URL passed to the XHR. 22 | class NetworkError extends Error 23 | # @private 24 | constructor: -> super() 25 | 26 | # Thrown if parsing URLs errors out. 27 | XMLHttpRequest.SyntaxError = SyntaxError 28 | 29 | class SyntaxError extends Error 30 | # @private: 31 | constructor: -> super() 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Victor Costan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /test/html/browser_test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | node-xhr2 browser tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /src/progress_event.coffee: -------------------------------------------------------------------------------- 1 | # http://xhr.spec.whatwg.org/#interface-progressevent 2 | class ProgressEvent 3 | # Creates a new event. 4 | # 5 | # @param {String} type the event type, e.g. 'readystatechange'; must be 6 | # lowercased 7 | constructor: (@type) -> 8 | @target = null 9 | @currentTarget = null 10 | @lengthComputable = false 11 | @loaded = 0 12 | @total = 0 13 | # Getting the time from the OS is expensive, skip on that for now. 14 | # @timeStamp = Date.now() 15 | 16 | # @property {Boolean} for compatibility with DOM events 17 | bubbles: false 18 | 19 | # @property {Boolean} for fompatibility with DOM events 20 | cancelable: false 21 | 22 | # @property {XMLHttpRequest} the request that caused this event 23 | target: null 24 | 25 | # @property {Number} number of bytes that have already been downloaded or 26 | # uploaded 27 | loaded: null 28 | 29 | # @property {Boolean} true if the Content-Length response header is available 30 | # and the value of the event's total property is meaningful 31 | lengthComputable: null 32 | 33 | # @property {Number} number of bytes that will be downloaded or uploaded by 34 | # the request that fired the event 35 | total: null 36 | 37 | 38 | # The XHR spec exports the ProgressEvent constructor. 39 | XMLHttpRequest.ProgressEvent = ProgressEvent 40 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # xhr2 Contribution Policy 2 | 3 | This is an open-source library and welcomes outside contributions. 4 | 5 | Please file bugs on 6 | [the GitHub issue page](https://github.com/pwnall/xhr2/issues). 7 | 8 | Please submit patches as 9 | [GitHub pull requests](https://help.github.com/articles/using-pull-requests). 10 | Please check the 11 | [existing pull requests](https://github.com/pwnall/xhr2/issues) to avoid 12 | duplicating effort. 13 | 14 | 15 | ## Pull Request Checklist 16 | 17 | * Do not modify the version in `package.json` or the commit history. Feel free 18 | to rebase your commits while the pull request is in progress. 19 | * If your patch adds new functionality, please make sure to include link to the 20 | relevant parts of the 21 | [W3C XMLHttpRequest specification](http://www.w3.org/TR/XMLHttpRequest/). Use 22 | the same style as existing source code. 23 | * Include tests whenever possible, so the functionality won't be broken by 24 | accident in future releases. 25 | 26 | 27 | ## Obligatory Legalese 28 | 29 | By submitting a contribution to the library, you grant Victor Costan 30 | (the library's author) a non-exclusive, irrevocable, perpetual, transferable, 31 | license to use, reproduce, modify, adapt, publish, translate, create derivative 32 | works from, distribute, perform and display your contribution (in whole or 33 | part) worldwide under the MIT license. 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xhr2", 3 | "version": "0.2.1", 4 | "description": "XMLHttpRequest emulation for node.js", 5 | "keywords": [ 6 | "xhr", 7 | "xmlhttprequest", 8 | "ajax", 9 | "browser" 10 | ], 11 | "homepage": "https://github.com/pwnall/node-xhr2", 12 | "author": "Victor Costan (http://www.costan.us)", 13 | "contributors": [ 14 | "Alexander Black ", 15 | "Daniel Friedman ", 16 | "Eugen Mayer ", 17 | "Francois-Xavier Kowalski ", 18 | "Sébastien Nicouleaud" 19 | ], 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/pwnall/node-xhr2.git" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/pwnall/node-xhr2/issues" 27 | }, 28 | "engines": { 29 | "node": ">= 6" 30 | }, 31 | "dependencies": {}, 32 | "devDependencies": { 33 | "async": ">=3.0.1", 34 | "chai": ">=4.2.0", 35 | "codo": ">=2.1.2", 36 | "coffeescript": ">=2.4.1", 37 | "express": ">=4.17.1", 38 | "glob": ">=7.1.4", 39 | "mocha": ">=6.1.4", 40 | "open": ">=6.3.0", 41 | "remove": ">= 0.1.5", 42 | "sinon": ">=7.3.2", 43 | "sinon-chai": ">=3.3.0" 44 | }, 45 | "main": "lib/xhr2.js", 46 | "browser": "lib/browser.js", 47 | "directories": { 48 | "doc": "doc", 49 | "lib": "lib", 50 | "src": "src", 51 | "test": "test" 52 | }, 53 | "scripts": { 54 | "prepublish": "cake build", 55 | "test": "cake test" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/src/helpers/setup.coffee: -------------------------------------------------------------------------------- 1 | if typeof window is 'undefined' 2 | # node.js 3 | global.XMLHttpRequest = require '../../../lib/xhr2' 4 | global.ProgressEvent = XMLHttpRequest.ProgressEvent 5 | global.NetworkError = XMLHttpRequest.NetworkError 6 | global.SecurityError = XMLHttpRequest.SecurityError 7 | global.InvalidStateError = XMLHttpRequest.InvalidStateError 8 | 9 | global.chai = require 'chai' 10 | global.assert = global.chai.assert 11 | global.expect = global.chai.expect 12 | global.sinon = require 'sinon' 13 | global.sinonChai = require 'sinon-chai' 14 | 15 | xhrServer = require './xhr_server' 16 | require './xhr2.png.js' 17 | 18 | https = require 'https' 19 | agent = new https.Agent 20 | agent.options.rejectUnauthorized = true 21 | agent.options.ca = xhrServer.https.sslCertificate() 22 | global.XMLHttpRequest.nodejsSet httpsAgent: agent 23 | global.XMLHttpRequest.nodejsSet( 24 | baseUrl: xhrServer.http.testUrl().replace('https://', 'http://')) 25 | else 26 | # browser 27 | 28 | # HACK(pwnall): the test is first loaded on https so the developer can bypass 29 | # the SSL interstitial; however, we need to run the test on http, because 30 | # Chrome blocks https -> http XHRs 31 | if window.location.href.indexOf('https://') is 0 32 | window.location.href = window.location.href.replace('https://', 'http://'). 33 | replace(':8911', ':8912') 34 | 35 | window.NetworkError = window.Error 36 | window.SecurityError = window.Error 37 | window.assert = window.chai.assert 38 | window.expect = window.chai.expect 39 | -------------------------------------------------------------------------------- /test/src/status_test.coffee: -------------------------------------------------------------------------------- 1 | describe 'XMLHttpRequest', -> 2 | beforeEach -> 3 | @xhr = new XMLHttpRequest 4 | @okUrl = 'http://localhost:8912/test/fixtures/hello.txt' 5 | 6 | @errorUrl = 'http://localhost:8912/_/response' 7 | @errorJson = JSON.stringify 8 | code: 401, status: 'Unauthorized', 9 | body: JSON.stringify(error: 'Credential error'), 10 | headers: 11 | 'Content-Type': 'application/json', 'Content-Length': '28' 12 | 13 | describe '#status', -> 14 | it 'is 200 for a normal request', (done) -> 15 | @xhr.open 'GET', @okUrl 16 | _done = false 17 | @xhr.addEventListener 'readystatechange', => 18 | return if _done 19 | if @xhr.readyState < XMLHttpRequest.HEADERS_RECEIVED 20 | expect(@xhr.status).to.equal 0 21 | expect(@xhr.statusText).to.equal '' 22 | else 23 | expect(@xhr.status).to.equal 200 24 | expect(@xhr.statusText).to.be.ok 25 | expect(@xhr.statusText).to.not.equal '' 26 | if @xhr.readyState is XMLHttpRequest.DONE 27 | _done = true 28 | done() 29 | @xhr.send() 30 | 31 | it 'returns the server-reported status', (done) -> 32 | @xhr.open 'POST', @errorUrl 33 | _done = false 34 | @xhr.addEventListener 'readystatechange', => 35 | return if _done 36 | if @xhr.readyState < XMLHttpRequest.HEADERS_RECEIVED 37 | expect(@xhr.status).to.equal 0 38 | expect(@xhr.statusText).to.equal '' 39 | else 40 | expect(@xhr.status).to.equal 401 41 | expect(@xhr.statusText).to.be.ok 42 | expect(@xhr.statusText).to.not.equal '' 43 | if @xhr.readyState is XMLHttpRequest.DONE 44 | _done = true 45 | done() 46 | @xhr.send @errorJson 47 | 48 | -------------------------------------------------------------------------------- /test/src/redirect_test.coffee: -------------------------------------------------------------------------------- 1 | describe 'XMLHttpRequest', -> 2 | describe 'when redirected', -> 3 | beforeEach -> 4 | @xhr = new XMLHttpRequest 5 | 6 | it 'issues a GET for the next location', (done) -> 7 | @xhr.open 'POST', 'http://localhost:8912/_/redirect/302/method' 8 | @xhr.onload = => 9 | expect(@xhr.responseText).to.match(/GET/i) 10 | done() 11 | @xhr.send 'This should be dropped during the redirect' 12 | 13 | it 'does not return the redirect headers', (done) -> 14 | @xhr.open 'GET', 'http://localhost:8912/_/redirect/302/method' 15 | @xhr.onload = => 16 | expect(@xhr.getResponseHeader('Content-Type')).to.equal( 17 | 'text/plain; charset=utf-8') 18 | expect(@xhr.getResponseHeader('X-Redirect-Header')).not.to.be.ok 19 | done() 20 | @xhr.send() 21 | 22 | it 'persists custom request headers across redirects', (done) -> 23 | @xhr.open 'GET', 'http://localhost:8912/_/redirect/302/headers' 24 | @xhr.setRequestHeader 'X-Redirect-Test', 'should be preserved' 25 | @xhr.onload = => 26 | expect(@xhr.responseText).to.match(/^\{.*\}$/) 27 | headers = JSON.parse @xhr.responseText 28 | expect(headers['connection']).to.equal 'keep-alive' 29 | expect(headers).to.have.property 'host' 30 | expect(headers['host']).to.equal 'localhost:8912' 31 | expect(headers).to.have.property 'x-redirect-test' 32 | expect(headers['x-redirect-test']).to.equal 'should be preserved' 33 | done() 34 | @xhr.send() 35 | 36 | it 'drops content-related headers across redirects', (done) -> 37 | @xhr.open 'POST', 'http://localhost:8912/_/redirect/302/headers' 38 | @xhr.setRequestHeader 'X-Redirect-Test', 'should be preserved' 39 | @xhr.onload = => 40 | expect(@xhr.responseText).to.match(/^\{.*\}$/) 41 | headers = JSON.parse @xhr.responseText 42 | expect(headers['connection']).to.equal 'keep-alive' 43 | expect(headers).to.have.property 'host' 44 | expect(headers['host']).to.equal 'localhost:8912' 45 | expect(headers).to.have.property 'x-redirect-test' 46 | expect(headers['x-redirect-test']).to.equal 'should be preserved' 47 | expect(headers).not.to.have.property 'content-type' 48 | expect(headers).not.to.have.property 'content-length' 49 | done() 50 | @xhr.send 'This should be dropped during the redirect' 51 | 52 | it 'provides the final responseURL', (done) -> 53 | @xhr.open 'GET', 'http://localhost:8912/_/redirect/302/method' 54 | @xhr.onload = => 55 | expect(@xhr.responseURL).to.equal("http://localhost:8912/_/method") 56 | done() 57 | @xhr.send() 58 | -------------------------------------------------------------------------------- /test/src/event_target_test.coffee: -------------------------------------------------------------------------------- 1 | describe 'XMLHttpRequestEventTarget', -> 2 | describe 'dispatchEvent', -> 3 | beforeEach -> 4 | @xhr = new XMLHttpRequest 5 | @loadEvent = new ProgressEvent 'load' 6 | 7 | it 'works with a DOM0 listener', -> 8 | count = 0 9 | @xhr.onload = (event) -> 10 | count += 1 11 | @xhr.dispatchEvent @loadEvent 12 | expect(count).to.equal 1 13 | 14 | it 'works with a DOM2 listener', -> 15 | count = 0 16 | @xhr.addEventListener 'load', (event) -> 17 | count += 1 18 | @xhr.dispatchEvent @loadEvent 19 | expect(count).to.equal 1 20 | 21 | it 'removes a DOM2 listener correctly', -> 22 | count = 0 23 | listener = (event) -> 24 | count += 1 25 | @xhr.addEventListener 'load', listener 26 | @xhr.dispatchEvent @loadEvent 27 | expect(count).to.equal 1 28 | 29 | count = 0 30 | @xhr.removeEventListener 'load', listener 31 | @xhr.dispatchEvent @loadEvent 32 | expect(count).to.equal 0 33 | 34 | it 'binds this correctly in a DOM0 listener', -> 35 | eventThis = null 36 | @xhr.onload = (event) -> 37 | eventThis = @ 38 | @xhr.dispatchEvent @loadEvent 39 | expect(eventThis).to.equal @xhr 40 | 41 | it 'binds this correctly in a DOM2 listener', -> 42 | eventThis = null 43 | @xhr.addEventListener 'load', (event) -> 44 | eventThis = @ 45 | @xhr.dispatchEvent @loadEvent 46 | expect(eventThis).to.equal @xhr 47 | 48 | it 'sets target correctly in a DOM0 listener', -> 49 | eventTarget = null 50 | @xhr.onload = (event) -> 51 | eventTarget = event.target 52 | @xhr.dispatchEvent @loadEvent 53 | expect(eventTarget).to.equal @xhr 54 | 55 | it 'sets target correctly in a DOM2 listener', -> 56 | eventTarget = null 57 | @xhr.addEventListener 'load', (event) -> 58 | eventTarget = event.target 59 | @xhr.dispatchEvent @loadEvent 60 | expect(eventTarget).to.equal @xhr 61 | 62 | it 'works with a DOM0 and two DOM2 listeners', -> 63 | count = [0, 0, 0] 64 | @xhr.addEventListener 'load', (event) -> 65 | count[1] += 1 66 | @xhr.onload = (event) -> 67 | count[0] += 1 68 | @xhr.addEventListener 'load', (event) -> 69 | count[2] += 1 70 | @xhr.dispatchEvent @loadEvent 71 | expect(count).to.deep.equal [1, 1, 1] 72 | 73 | it 'does not invoke a DOM0 listener for a different event', -> 74 | count = 0 75 | @xhr.onerror = (event) -> 76 | count += 1 77 | @xhr.dispatchEvent @loadEvent 78 | expect(count).to.equal 0 79 | 80 | it 'does not invoke a DOM2 listener for a different event', -> 81 | count = 0 82 | @xhr.addEventListener 'error', (event) -> 83 | count += 1 84 | @xhr.dispatchEvent @loadEvent 85 | expect(count).to.equal 0 86 | -------------------------------------------------------------------------------- /src/000-xml_http_request_event_target.coffee: -------------------------------------------------------------------------------- 1 | # This file's name is set up in such a way that it will always show up first in 2 | # the list of files given to coffee --join, so that the other files can assume 3 | # that XMLHttpRequestEventTarget was already defined. 4 | 5 | 6 | # The DOM EventTarget subclass used by XMLHttpRequest. 7 | # 8 | # @see http://xhr.spec.whatwg.org/#interface-xmlhttprequest 9 | class XMLHttpRequestEventTarget 10 | # @private 11 | # This is an abstract class and should not be instantiated directly. 12 | constructor: -> 13 | @onloadstart = null 14 | @onprogress = null 15 | @onabort = null 16 | @onerror = null 17 | @onload = null 18 | @ontimeout = null 19 | @onloadend = null 20 | @_listeners = {} 21 | 22 | # @property {function(ProgressEvent)} DOM level 0-style handler 23 | # for the 'loadstart' event 24 | onloadstart: null 25 | 26 | # @property {function(ProgressEvent)} DOM level 0-style handler 27 | # for the 'progress' event 28 | onprogress: null 29 | 30 | # @property {function(ProgressEvent)} DOM level 0-style handler 31 | # for the 'abort' event 32 | onabort: null 33 | 34 | # @property {function(ProgressEvent)} DOM level 0-style handler 35 | # for the 'error' event 36 | onerror: null 37 | 38 | # @property {function(ProgressEvent)} DOM level 0-style handler 39 | # for the 'load' event 40 | onload: null 41 | 42 | # @property {function(ProgressEvent)} DOM level 0-style handler 43 | # for the 'timeout' event 44 | ontimeout: null 45 | 46 | # @property {function(ProgressEvent)} DOM level 0-style handler 47 | # for the 'loadend' event 48 | onloadend: null 49 | 50 | # Adds a new-style listener for one of the XHR events. 51 | # 52 | # @see http://www.w3.org/TR/XMLHttpRequest/#events 53 | # 54 | # @param {String} eventType an XHR event type, such as 'readystatechange' 55 | # @param {function(ProgressEvent)} listener function that will be called when 56 | # the event fires 57 | # @return {undefined} undefined 58 | addEventListener: (eventType, listener) -> 59 | eventType = eventType.toLowerCase() 60 | @_listeners[eventType] ||= [] 61 | @_listeners[eventType].push listener 62 | undefined 63 | 64 | # Removes an event listener added by calling addEventListener. 65 | # 66 | # @param {String} eventType an XHR event type, such as 'readystatechange' 67 | # @param {function(ProgressEvent)} listener the value passed in a previous 68 | # call to addEventListener. 69 | # @return {undefined} undefined 70 | removeEventListener: (eventType, listener) -> 71 | eventType = eventType.toLowerCase() 72 | if @_listeners[eventType] 73 | index = @_listeners[eventType].indexOf listener 74 | @_listeners[eventType].splice index, 1 if index isnt -1 75 | undefined 76 | 77 | # Calls all the listeners for an event. 78 | # 79 | # @param {ProgressEvent} event the event to be dispatched 80 | # @return {undefined} undefined 81 | dispatchEvent: (event) -> 82 | event.currentTarget = event.target = @ 83 | eventType = event.type 84 | if listeners = @_listeners[eventType] 85 | for listener in listeners 86 | listener.call @, event 87 | if listener = @["on#{eventType}"] 88 | listener.call @, event 89 | undefined 90 | -------------------------------------------------------------------------------- /src/xml_http_request_upload.coffee: -------------------------------------------------------------------------------- 1 | # @see http://xhr.spec.whatwg.org/#interface-xmlhttprequest 2 | class XMLHttpRequestUpload extends XMLHttpRequestEventTarget 3 | # @private 4 | # @param {XMLHttpRequest} the XMLHttpRequest that this upload object is 5 | # associated with 6 | constructor: (request) -> 7 | super() 8 | @_request = request 9 | @_reset() 10 | 11 | # Sets up this Upload to handle a new request. 12 | # 13 | # @private 14 | # @return {undefined} undefined 15 | _reset: -> 16 | @_contentType = null 17 | @_body = null 18 | undefined 19 | 20 | # Implements the upload-related part of the send() XHR specification. 21 | # 22 | # @private 23 | # @param {?String, ?Buffer, ?ArrayBufferView} data the argument passed to 24 | # XMLHttpRequest#send() 25 | # @return {undefined} undefined 26 | # @see step 4 of http://www.w3.org/TR/XMLHttpRequest/#the-send()-method 27 | _setData: (data) -> 28 | if typeof data is 'undefined' or data is null 29 | return 30 | 31 | if typeof data is 'string' 32 | # DOMString 33 | if data.length isnt 0 34 | @_contentType = 'text/plain;charset=UTF-8' 35 | @_body = Buffer.from data, 'utf8' 36 | else if Buffer.isBuffer data 37 | # node.js Buffer 38 | @_body = data 39 | else if data instanceof ArrayBuffer 40 | # ArrayBuffer arguments were supported in an old revision of the spec. 41 | body = Buffer.alloc data.byteLength 42 | view = new Uint8Array data 43 | body[i] = view[i] for i in [0...data.byteLength] 44 | @_body = body 45 | else if data.buffer and data.buffer instanceof ArrayBuffer 46 | # ArrayBufferView 47 | body = Buffer.alloc data.byteLength 48 | offset = data.byteOffset 49 | view = new Uint8Array data.buffer 50 | body[i] = view[i + offset] for i in [0...data.byteLength] 51 | @_body = body 52 | else 53 | # NOTE: diverging from the XHR specification of coercing everything else 54 | # to Strings via toString() because that behavior masks bugs and is 55 | # rarely useful 56 | throw new Error "Unsupported send() data #{data}" 57 | 58 | undefined 59 | 60 | # Updates the HTTP headers right before the request is sent. 61 | # 62 | # This is used to set data-dependent headers such as Content-Length and 63 | # Content-Type. 64 | # 65 | # @private 66 | # @param {Object} headers the HTTP headers to be sent 67 | # @param {Object} loweredHeaders maps lowercased HTTP header 68 | # names (e.g., 'content-type') to the actual names used in the headers 69 | # parameter (e.g., 'Content-Type') 70 | # @return {undefined} undefined 71 | _finalizeHeaders: (headers, loweredHeaders) -> 72 | if @_contentType 73 | unless 'content-type' of loweredHeaders 74 | headers['Content-Type'] = @_contentType 75 | 76 | if @_body 77 | # Restricted headers can't be set by the user, no need to check 78 | # loweredHeaders. 79 | headers['Content-Length'] = @_body.length.toString() 80 | 81 | undefined 82 | 83 | # Starts sending the HTTP request data. 84 | # 85 | # @private 86 | # @param {http.ClientRequest} request the HTTP request 87 | # @return {undefined} undefined 88 | _startUpload: (request) -> 89 | request.write @_body if @_body 90 | request.end() 91 | 92 | undefined 93 | 94 | # Export the XMLHttpRequestUpload constructor. 95 | XMLHttpRequest.XMLHttpRequestUpload = XMLHttpRequestUpload 96 | -------------------------------------------------------------------------------- /test/src/response_type_test.coffee: -------------------------------------------------------------------------------- 1 | describe 'XMLHttpRequest', -> 2 | describe '#responseType', -> 3 | beforeEach -> 4 | @xhr = new XMLHttpRequest 5 | @jsonUrl = 'http://localhost:8912/test/fixtures/hello.json' 6 | @jsonString = '{"hello": "world", "answer": 42}\n' 7 | @imageUrl = 'http://localhost:8912/test/fixtures/xhr2.png' 8 | 9 | describe 'text', -> 10 | it 'reads a JSON file into a String', (done) -> 11 | @xhr.addEventListener 'load', => 12 | expect(@xhr.response).to.equal @jsonString 13 | expect(@xhr.responseText).to.equal @jsonString 14 | done() 15 | @xhr.open 'GET', @jsonUrl 16 | @xhr.responseType = 'text' 17 | @xhr.send() 18 | 19 | describe 'json', -> 20 | it 'reads a JSON file into a parsed JSON object', (done) -> 21 | _done = false 22 | @xhr.addEventListener 'readystatechange', => 23 | return if _done or @xhr.readyState isnt XMLHttpRequest.DONE 24 | _done = true 25 | expect(@xhr.response).to.deep.equal hello: 'world', answer: 42 26 | done() 27 | @xhr.open 'GET', @jsonUrl 28 | @xhr.responseType = 'json' 29 | @xhr.send() 30 | 31 | it 'produces null when reading a non-JSON file ', (done) -> 32 | @xhr.addEventListener 'loadend', => 33 | expect(@xhr.response).to.equal null 34 | done() 35 | @xhr.open 'GET', 'http://localhost:8912/test/fixtures/hello.txt' 36 | @xhr.responseType = 'json' 37 | @xhr.send() 38 | 39 | describe 'arraybuffer', -> 40 | it 'reads a JSON file into an ArrayBuffer', (done) -> 41 | @xhr.addEventListener 'loadend', => 42 | expect(@xhr.response).to.be.instanceOf ArrayBuffer 43 | view = new Uint8Array @xhr.response 44 | string = (String.fromCharCode(view[i]) for i in [0...view.length]). 45 | join '' 46 | expect(string).to.equal @jsonString 47 | done() 48 | @xhr.open 'GET', @jsonUrl 49 | @xhr.responseType = 'arraybuffer' 50 | @xhr.send() 51 | 52 | it 'reads a binary file into an ArrayBuffer', (done) -> 53 | @xhr.addEventListener 'loadend', => 54 | expect(@xhr.response).to.be.instanceOf ArrayBuffer 55 | view = new Uint8Array @xhr.response 56 | bytes = (view[i] for i in [0...view.length]) 57 | expect(bytes).to.deep.equal xhr2PngBytes 58 | done() 59 | @xhr.open 'GET', @imageUrl 60 | @xhr.responseType = 'arraybuffer' 61 | @xhr.send() 62 | 63 | 64 | describe 'buffer', -> 65 | it 'reads a JSON file into a node.js Buffer', (done) -> 66 | return done() if typeof Buffer is 'undefined' 67 | @xhr.addEventListener 'loadend', => 68 | buffer = @xhr.response 69 | expect(buffer).to.be.instanceOf Buffer 70 | stringChars = for i in [0...buffer.length] 71 | String.fromCharCode buffer.readUInt8(i) 72 | expect(stringChars.join('')).to.equal @jsonString 73 | done() 74 | @xhr.open 'GET', @jsonUrl 75 | @xhr.responseType = 'buffer' 76 | @xhr.send() 77 | 78 | it 'reads a binary file into a node.js Buffer', (done) -> 79 | return done() if typeof Buffer is 'undefined' 80 | @xhr.addEventListener 'loadend', => 81 | buffer = @xhr.response 82 | expect(buffer).to.be.instanceOf Buffer 83 | bytes = (buffer.readUInt8(i) for i in [0...buffer.length]) 84 | expect(bytes).to.deep.equal xhr2PngBytes 85 | done() 86 | @xhr.open 'GET', @imageUrl 87 | @xhr.responseType = 'buffer' 88 | @xhr.send() 89 | -------------------------------------------------------------------------------- /test/src/xhr_test.coffee: -------------------------------------------------------------------------------- 1 | describe 'XMLHttpRequest', -> 2 | beforeEach -> 3 | @xhr = new XMLHttpRequest 4 | 5 | describe 'constructor', -> 6 | it 'sets readyState to UNSENT', -> 7 | expect(@xhr.readyState).to.equal XMLHttpRequest.UNSENT 8 | 9 | it 'sets timeout to 0', -> 10 | expect(@xhr.timeout).to.equal 0 11 | 12 | it 'sets responseType to ""', -> 13 | expect(@xhr.responseType).to.equal '' 14 | 15 | it 'sets status to 0', -> 16 | expect(@xhr.status).to.equal 0 17 | 18 | it 'sets statusText to ""', -> 19 | expect(@xhr.statusText).to.equal '' 20 | 21 | describe '#open', -> 22 | it 'throws SecurityError on CONNECT', -> 23 | expect(=> @xhr.open 'CONNECT', 'http://localhost:8912/test').to. 24 | throw(SecurityError) 25 | 26 | describe 'with a GET for a local https request', -> 27 | beforeEach -> 28 | @xhr.open 'GET', 'https://localhost:8911/test/fixtures/hello.txt' 29 | 30 | it 'sets readyState to OPENED', -> 31 | expect(@xhr.readyState).to.equal XMLHttpRequest.OPENED 32 | 33 | it 'keeps status 0', -> 34 | expect(@xhr.status).to.equal 0 35 | 36 | it 'keeps statusText ""', -> 37 | expect(@xhr.statusText).to.equal '' 38 | 39 | describe '#send', -> 40 | describe 'on a local http GET', -> 41 | beforeEach -> 42 | @xhr.open 'GET', 'http://localhost:8912/test/fixtures/hello.txt' 43 | 44 | it 'kicks off the request', (done) -> 45 | @xhr.onload = (event) => 46 | expect(@xhr.status).to.equal 200 47 | expect(@xhr.responseText).to.equal 'Hello world!\n' 48 | done() 49 | @xhr.send() 50 | 51 | describe 'on a local https GET', -> 52 | beforeEach -> 53 | @xhr.open 'GET', 'https://localhost:8911/test/fixtures/hello.txt' 54 | 55 | it 'kicks off the request', (done) -> 56 | @xhr.onload = (event) => 57 | expect(@xhr.status).to.equal 200 58 | expect(@xhr.responseText).to.equal 'Hello world!\n' 59 | done() 60 | @xhr.send() 61 | 62 | describe 'on a local relative GET', -> 63 | beforeEach -> 64 | @xhr.open 'GET', '../fixtures/hello.txt' 65 | 66 | it 'kicks off the request', (done) -> 67 | @xhr.onload = (event) => 68 | expect(@xhr.status).to.equal 200 69 | expect(@xhr.responseText).to.equal 'Hello world!\n' 70 | done() 71 | @xhr.send() 72 | 73 | describe 'on a local gopher GET', -> 74 | describe '#open + #send', -> 75 | it 'throw a NetworkError', -> 76 | expect(=> 77 | @xhr.open 'GET', 'gopher:localhost:8911' 78 | @xhr.send() 79 | ).to.throw(NetworkError) 80 | 81 | describe 'readyState constants', -> 82 | it 'UNSENT < OPENED', -> 83 | expect(XMLHttpRequest.UNSENT).to.be.below(XMLHttpRequest.OPENED) 84 | 85 | it 'OPENED < HEADERS_RECEIVED', -> 86 | expect(XMLHttpRequest.OPENED).to.be. 87 | below(XMLHttpRequest.HEADERS_RECEIVED) 88 | 89 | it 'HEADERS_RECEIVED < LOADING', -> 90 | expect(XMLHttpRequest.HEADERS_RECEIVED).to.be. 91 | below(XMLHttpRequest.LOADING) 92 | 93 | it 'LOADING < DONE', -> 94 | expect(XMLHttpRequest.LOADING).to.be.below(XMLHttpRequest.DONE) 95 | 96 | it 'XMLHttpRequest constants match the instance costants', -> 97 | expect(XMLHttpRequest.UNSENT).to.equal @xhr.UNSENT 98 | expect(XMLHttpRequest.OPENED).to.equal @xhr.OPENED 99 | expect(XMLHttpRequest.HEADERS_RECEIVED).to.equal @xhr.HEADERS_RECEIVED 100 | expect(XMLHttpRequest.LOADING).to.equal @xhr.LOADING 101 | expect(XMLHttpRequest.DONE).to.equal @xhr.DONE 102 | 103 | -------------------------------------------------------------------------------- /test/src/send_test.coffee: -------------------------------------------------------------------------------- 1 | describe 'XMLHttpRequest', -> 2 | describe '#send', -> 3 | beforeEach -> 4 | @xhr = new XMLHttpRequest 5 | @xhr.open 'POST', 'http://localhost:8912/_/echo' 6 | 7 | @arrayBuffer = new ArrayBuffer xhr2PngBytes.length 8 | @arrayBufferView = new Uint8Array @arrayBuffer 9 | if typeof Buffer is 'undefined' 10 | @buffer = null 11 | else 12 | @buffer = Buffer.alloc xhr2PngBytes.length 13 | 14 | for i in [0...xhr2PngBytes.length] 15 | @arrayBufferView[i] = xhr2PngBytes[i] 16 | @buffer.writeUInt8 xhr2PngBytes[i], i if @buffer 17 | 18 | it 'works with ASCII DOMStrings', (done) -> 19 | @xhr.onload = => 20 | expect(@xhr.getResponseHeader('content-type')).to. 21 | match(/^text\/plain(;\s?charset=UTF-8)?$/) 22 | expect(@xhr.responseText).to.equal 'Hello world!' 23 | done() 24 | @xhr.send "Hello world!" 25 | 26 | it 'works with UTF-8 DOMStrings', (done) -> 27 | @xhr.onloadend = => 28 | expect(@xhr.getResponseHeader('content-type')).to. 29 | match(/^text\/plain(;\s?charset=UTF-8)?$/) 30 | expect(@xhr.responseText).to.equal '世界你好!' 31 | done() 32 | @xhr.send '世界你好!' 33 | 34 | it 'works with ArrayBufferViews', (done) -> 35 | @xhr.responseType = 'arraybuffer' 36 | @xhr.onload = => 37 | expect(@xhr.getResponseHeader('content-type')).to.equal null 38 | responseView = new Uint8Array @xhr.response 39 | responseBytes = (responseView[i] for i in [0...responseView.length]) 40 | expect(responseBytes).to.deep.equal xhr2PngBytes 41 | done() 42 | @xhr.send @arrayBufferView 43 | 44 | it 'works with ArrayBufferViews with set index and length', (done) -> 45 | @xhr.responseType = 'arraybuffer' 46 | @xhr.onload = => 47 | expect(@xhr.getResponseHeader('content-type')).to.equal null 48 | responseView = new Uint8Array @xhr.response 49 | responseBytes = (responseView[i] for i in [0...responseView.length]) 50 | expect(responseBytes).to.deep.equal xhr2PngBytes[10...52] 51 | done() 52 | arrayBufferView10 = new Uint8Array @arrayBuffer, 10, 42 53 | @xhr.send arrayBufferView10 54 | 55 | it 'works with ArrayBuffers', (done) -> 56 | @xhr.responseType = 'arraybuffer' 57 | @xhr.onload = => 58 | expect(@xhr.getResponseHeader('content-type')).to.equal null 59 | responseView = new Uint8Array @xhr.response 60 | responseBytes = (responseView[i] for i in [0...responseView.length]) 61 | expect(responseBytes).to.deep.equal xhr2PngBytes 62 | done() 63 | @xhr.send @arrayBuffer 64 | 65 | it 'works with node.js Buffers', (done) -> 66 | return done() unless @buffer 67 | # NOTE: using the same exact code as above, which is tested in a browser 68 | @xhr.responseType = 'arraybuffer' 69 | @xhr.onload = => 70 | expect(@xhr.getResponseHeader('content-type')).to.equal null 71 | responseView = new Uint8Array @xhr.response 72 | responseBytes = (responseView[i] for i in [0...responseView.length]) 73 | expect(responseBytes).to.deep.equal xhr2PngBytes 74 | done() 75 | @xhr.send @buffer 76 | 77 | it 'sets POST headers correctly when given null data', (done) -> 78 | @xhr.open 'POST', 'http://localhost:8912/_/headers' 79 | @xhr.responseType = 'text' 80 | @xhr.onload = => 81 | expect(@xhr.responseText).to.match(/^\{.*\}$/) 82 | headers = JSON.parse @xhr.responseText 83 | expect(headers).to.have.property 'content-length' 84 | expect(headers['content-length']).to.equal '0' 85 | expect(headers).not.to.have.property 'content-type' 86 | done() 87 | @xhr.send() 88 | 89 | 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XMLHttpRequest Emulation for node.js 2 | 3 | This is an [npm](https://npmjs.org/) package that implements the 4 | [W3C XMLHttpRequest](http://www.w3.org/TR/XMLHttpRequest/) specification on top 5 | of the [node.js](http://nodejs.org/) APIs. 6 | 7 | 8 | ## Supported Platforms 9 | 10 | This library is tested against the following platforms. 11 | 12 | * [node.js](http://nodejs.org/) 10 13 | * [node.js](http://nodejs.org/) 12 14 | 15 | Keep in mind that the versions above are not hard requirements. 16 | 17 | 18 | ## Installation and Usage 19 | 20 | The preferred installation method is to add the library to the `dependencies` 21 | section in your `package.json`. 22 | 23 | ```json 24 | { 25 | "dependencies": { 26 | "xhr2": "*" 27 | } 28 | } 29 | ``` 30 | 31 | Alternatively, `npm` can be used to install the library directly. 32 | 33 | ```bash 34 | npm install xhr2 35 | ``` 36 | 37 | Once the library is installed, `require`-ing it returns the `XMLHttpRequest` 38 | constructor. 39 | 40 | ```javascript 41 | var XMLHttpRequest = require('xhr2'); 42 | ``` 43 | 44 | The other objects that are usually defined in an XHR environment are hanging 45 | off of `XMLHttpRequest`. 46 | 47 | ```javascript 48 | var XMLHttpRequestUpload = XMLHttpRequest.XMLHttpRequestUpload; 49 | ``` 50 | 51 | MDN (the Mozilla Developer Network) has a 52 | [great intro to XMLHttpRequest](https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest/Using_XMLHttpRequest). 53 | 54 | This library's [CoffeeDocs](http://coffeedoc.info/github/pwnall/node-xhr2/) can 55 | be used as quick reference to the XMLHttpRequest specification parts that were 56 | implemented. 57 | 58 | 59 | ## Features 60 | 61 | The following standard features are implemented. 62 | 63 | * `http` and `https` URI protocols 64 | * Basic authentication according to the XMLHttpRequest specification 65 | * request and response header management 66 | * `send()` accepts the following data types: String, ArrayBufferView, 67 | ArrayBuffer (deprecated in the standard) 68 | * `responseType` values: `text`, `json`, `arraybuffer` 69 | * `readystatechange` and download progress events 70 | * `overrideMimeType()` 71 | * `abort()` 72 | * `timeout` 73 | * automated redirection following 74 | 75 | The following node.js extensions are implemented. 76 | 77 | * `send()` accepts a node.js Buffer 78 | * Setting `responseType` to `buffer` produces a node.js Buffer 79 | * `nodejsSet` does XHR network configuration that is not exposed in browsers, 80 | for security reasons 81 | 82 | The following standard features are not implemented. 83 | 84 | * FormData 85 | * Blob 86 | * `file://` URIs 87 | * `data:` URIs 88 | * upload progress events 89 | * synchronous operation 90 | * Same-origin policy checks and CORS 91 | * cookie processing 92 | 93 | 94 | ## Versioning 95 | 96 | The library aims to implement the 97 | [W3C XMLHttpRequest](http://www.w3.org/TR/XMLHttpRequest/) specification, so 98 | the library's API will always be a (hopefully growing) subset of the API in the 99 | specification. 100 | 101 | 102 | ## Development 103 | 104 | The following commands will get the source tree in a `node-xhr2/` directory and 105 | build the library. 106 | 107 | ```bash 108 | git clone git://github.com/pwnall/node-xhr2.git 109 | cd node-xhr2 110 | npm install 111 | npm pack 112 | ``` 113 | 114 | Installing CoffeeScript globally will let you type `cake` instead of 115 | `node_modules/.bin/cake` 116 | 117 | ```bash 118 | npm install -g coffeescript 119 | ``` 120 | 121 | The library comes with unit tests that exercise the XMLHttpRequest API. 122 | 123 | ```bash 124 | cake test 125 | ``` 126 | 127 | The tests themselves can be tested by running them in a browser environment, 128 | where a different XMLHttpRequest implementation is available. Both Google 129 | Chrome and Firefox deviate from the specification in small ways, so it's best 130 | to run the tests in both browsers and mentally compute an intersection of the 131 | failing tests. 132 | 133 | ```bash 134 | cake webtest 135 | BROWSER=firefox cake webtest 136 | ``` 137 | 138 | 139 | ## Copyright and License 140 | 141 | The library is Copyright (c) 2013 Victor Costan, and distributed under the MIT 142 | License. 143 | -------------------------------------------------------------------------------- /Cakefile: -------------------------------------------------------------------------------- 1 | async = require 'async' 2 | {spawn, exec} = require 'child_process' 3 | fs = require 'fs' 4 | glob = require 'glob' 5 | log = console.log 6 | path = require 'path' 7 | remove = require 'remove' 8 | 9 | # Node 0.6 compatibility hack. 10 | unless fs.existsSync 11 | fs.existsSync = (filePath) -> path.existsSync filePath 12 | 13 | 14 | task 'build', -> 15 | build() 16 | 17 | task 'test', -> 18 | vendor -> 19 | build -> 20 | ssl_cert -> 21 | test_cases = glob.sync 'test/js/**/*_test.js' 22 | test_cases.sort() # Consistent test case order. 23 | run 'node_modules/.bin/mocha --colors --slow 200 --timeout 1000 --exit ' + 24 | "--require test/js/helpers/setup.js #{test_cases.join(' ')}" 25 | 26 | task 'webtest', -> 27 | vendor -> 28 | build -> 29 | ssl_cert -> 30 | webtest() 31 | 32 | task 'cert', -> 33 | remove.removeSync 'test/ssl', ignoreMissing: true 34 | ssl_cert() 35 | 36 | task 'vendor', -> 37 | remove.removeSync './test/vendor', ignoreMissing: true 38 | vendor() 39 | 40 | task 'doc', -> 41 | run 'node_modules/.bin/codo --title "node-xhr API Documentation" src' 42 | 43 | build = (callback) -> 44 | commands = [] 45 | 46 | # Ignoring ".coffee" when sorting. 47 | # We want "driver.coffee" to sort before "driver-browser.coffee" 48 | source_files = glob.sync 'src/**/*.coffee' 49 | source_files.sort (a, b) -> 50 | a.replace(/\.coffee$/, '').localeCompare b.replace(/\.coffee$/, '') 51 | 52 | # Compile without --join for decent error messages. 53 | commands.push 'node_modules/.bin/coffee --output tmp --compile ' + 54 | source_files.join(' ') 55 | commands.push 'node_modules/.bin/coffee --output lib --compile ' + 56 | "--join xhr2.js #{source_files.join(' ')}" 57 | 58 | # Tests are supposed to be independent, so the build order doesn't matter. 59 | test_dirs = glob.sync 'test/src/**/' 60 | for test_dir in test_dirs 61 | out_dir = test_dir.replace(/^test\/src\//, 'test/js/') 62 | test_files = glob.sync path.join(test_dir, '*.coffee') 63 | commands.push "node_modules/.bin/coffee --output #{out_dir} " + 64 | "--compile #{test_files.join(' ')}" 65 | 66 | async.forEachSeries commands, run, -> 67 | # Build the binary test image. 68 | buffer = fs.readFileSync 'test/fixtures/xhr2.png' 69 | bytes = (buffer.readUInt8(i) for i in [0...buffer.length]) 70 | globalJs = '((function(){ return this.global || this; })())' 71 | js = "#{globalJs}.xhr2PngBytes = #{JSON.stringify(bytes)};" 72 | fs.writeFile 'test/js/helpers/xhr2.png.js', js, -> 73 | callback() if callback 74 | 75 | webtest = (callback) -> 76 | xhrServer = require './test/js/helpers/xhr_server.js' 77 | if 'BROWSER' of process.env 78 | if process.env['BROWSER'] is 'false' 79 | url = xhrServer.https.testUrl() 80 | console.log "Please open the URL below in your browser:\n #{url}" 81 | else 82 | xhrServer.https.openBrowser process.env['BROWSER'] 83 | else 84 | xhrServer.https.openBrowser() 85 | callback() if callback? 86 | 87 | ssl_cert = (callback) -> 88 | if fs.existsSync 'test/ssl/cert.pem' 89 | callback() if callback? 90 | return 91 | 92 | fs.mkdirSync 'test/ssl' unless fs.existsSync 'test/ssl' 93 | run 'openssl req -new -x509 -days 365 -nodes -sha256 -newkey rsa:2048 ' + 94 | '-batch -out test/ssl/cert.pem -keyout test/ssl/cert.pem ' + 95 | '-subj /O=xhr2.js/OU=Testing/CN=localhost ', callback 96 | 97 | vendor = (callback) -> 98 | # All the files will be dumped here. 99 | fs.mkdirSync 'test/vendor' unless fs.existsSync 'test/vendor' 100 | 101 | downloads = [ 102 | # chai.js ships different builds for browsers vs node.js 103 | ['https://www.chaijs.com/chai.js', 'test/vendor/chai.js'], 104 | # sinon.js also ships special builds for browsers 105 | ['https://sinonjs.org/releases/sinon.js', 'test/vendor/sinon.js'], 106 | ] 107 | async.forEachSeries downloads, download, -> 108 | callback() if callback 109 | 110 | run = (args...) -> 111 | for a in args 112 | switch typeof a 113 | when 'string' then command = a 114 | when 'object' 115 | if a instanceof Array then params = a 116 | else options = a 117 | when 'function' then callback = a 118 | 119 | command += ' ' + params.join ' ' if params? 120 | cmd = spawn '/bin/sh', ['-c', command], options 121 | cmd.stdout.on 'data', (data) -> process.stdout.write data 122 | cmd.stderr.on 'data', (data) -> process.stderr.write data 123 | process.on 'SIGHUP', -> cmd.kill() 124 | cmd.on 'exit', (code) -> callback() if callback? and code is 0 125 | 126 | download = ([url, file], callback) -> 127 | if fs.existsSync file 128 | callback() if callback? 129 | return 130 | 131 | run "curl -o #{file} #{url}", callback 132 | -------------------------------------------------------------------------------- /test/src/helpers/xhr_server.coffee: -------------------------------------------------------------------------------- 1 | express = require 'express' 2 | fs = require 'fs' 3 | http = require 'http' 4 | https = require 'https' 5 | open = require 'open' 6 | 7 | # express.js server for testing the Web application. 8 | class XhrServer 9 | # Starts up a HTTP server. 10 | constructor: (@port, @useHttps) -> 11 | @createApp() 12 | 13 | # Opens the test URL in a browser. 14 | openBrowser: (appName) -> 15 | open @testUrl(), appName 16 | 17 | # The URL that should be used to start the tests. 18 | testUrl: -> 19 | "https://localhost:#{@port}/test/html/browser_test.html" 20 | 21 | # The self-signed certificate used by this server. 22 | sslCertificate: -> 23 | return null unless @useHttps 24 | keyMaterial = fs.readFileSync 'test/ssl/cert.pem', 'utf8' 25 | certIndex = keyMaterial.indexOf '-----BEGIN CERTIFICATE-----' 26 | keyMaterial.substring certIndex 27 | 28 | # The key for the self-signed certificate used by this server. 29 | sslKey: -> 30 | return null unless @useHttps 31 | keyMaterial = fs.readFileSync 'test/ssl/cert.pem', 'utf8' 32 | certIndex = keyMaterial.indexOf '-----BEGIN CERTIFICATE-----' 33 | keyMaterial.substring 0, certIndex 34 | 35 | # The server code. 36 | createApp: -> 37 | @app = express() 38 | 39 | ## Middleware. 40 | 41 | # CORS headers on everything, in case that ever gets implemented. 42 | @app.use (request, response, next) -> 43 | response.header 'Access-Control-Allow-Origin', '*' 44 | response.header 'Access-Control-Allow-Methods', 'DELETE,GET,POST,PUT' 45 | response.header 'Access-Control-Allow-Headers', 46 | 'Content-Type, Authorization' 47 | next() 48 | 49 | @app.use express.static(fs.realpathSync(__dirname + '/../../../'), 50 | { dotfiles: 'allow' }) 51 | 52 | ## Routes 53 | 54 | @app.all '/_/method', (request, response) -> 55 | body = request.method 56 | response.header 'Content-Type', 'text/plain; charset=utf-8' 57 | response.header 'Content-Length', body.length.toString() 58 | response.end body 59 | 60 | # Echoes the request body. Used to test send(data). 61 | @app.post '/_/echo', (request, response) -> 62 | if request.headers['content-type'] 63 | response.header 'Content-Type', request.headers['content-type'] 64 | if request.headers['content-length'] 65 | response.header 'Content-Length', request.headers['content-length'] 66 | 67 | request.on 'data', (chunk) -> response.write chunk 68 | request.on 'end', -> response.end() 69 | 70 | # Lists the request headers. Used to test setRequestHeader(). 71 | @app.all '/_/headers', (request, response) -> 72 | body = JSON.stringify request.headers 73 | response.header 'Content-Type', 'application/json' 74 | response.header 'Content-Length', body.length.toString() 75 | response.end body 76 | 77 | # Sets the response headers in the request. Used to test getResponse*(). 78 | @app.post '/_/get_headers', (request, response) -> 79 | jsonString = '' 80 | request.on 'data', (chunk) -> jsonString += chunk 81 | request.on 'end', -> 82 | headers = JSON.parse jsonString 83 | for name, value of headers 84 | response.header name, value 85 | response.header 'Content-Length', '0' 86 | response.end '' 87 | 88 | # Sets every response detail. Used for error testing. 89 | @app.post '/_/response', (request, response) -> 90 | jsonString = '' 91 | request.on 'data', (chunk) -> jsonString += chunk 92 | request.on 'end', -> 93 | json = JSON.parse jsonString 94 | response.writeHead json.code, json.status, json.headers 95 | response.write json.body if json.body 96 | response.end() 97 | 98 | # Sends data in small chunks. Used for event testing. 99 | @app.post '/_/drip', (request, response) -> 100 | request.connection.setNoDelay() 101 | jsonString = '' 102 | request.on 'data', (chunk) -> jsonString += chunk 103 | request.on 'end', -> 104 | json = JSON.parse jsonString 105 | sentDrips = 0 106 | drip = new Array(json.size + 1).join '.' 107 | response.header 'Content-Type', 'text/plain' 108 | if json.length 109 | response.header 'Content-Length', (json.drips * json.size).toString() 110 | sendDrip = => 111 | response.write drip 112 | sentDrips += 1 113 | if sentDrips >= json.drips 114 | response.end() 115 | else 116 | setTimeout sendDrip, json.ms 117 | sendDrip() 118 | 119 | # Returns a HTTP redirect. Used to test the redirection handling code. 120 | @app.all '/_/redirect/:status/:next_page', (request, response) => 121 | response.statusCode = parseInt(request.params.status) 122 | response.header 'Location', 123 | "http://#{request.get('host')}/_/#{request.params.next_page}" 124 | body = "

This is supposed to have a redirect link

" 125 | response.header 'Content-Type', 'text/html' 126 | response.header 'Content-Length', body.length.toString() 127 | response.header 'X-Redirect-Header', 'should not show up' 128 | response.end body 129 | 130 | # Requested when the browser test suite completes. 131 | @app.get '/diediedie', (request, response) => 132 | if 'failed' of request.query 133 | failed = parseInt request.query['failed'] 134 | else 135 | failed = 1 136 | total = parseInt request.query['total'] || 0 137 | passed = total - failed 138 | exitCode = if failed == 0 then 0 else 1 139 | console.log "#{passed} passed, #{failed} failed" 140 | 141 | response.header 'Content-Type', 'image/png' 142 | response.header 'Content-Length', '0' 143 | response.end '' 144 | unless 'NO_EXIT' of process.env 145 | @server.close() 146 | process.exit exitCode 147 | 148 | if @useHttps 149 | options = key: @sslKey(), cert: @sslCertificate() 150 | @server = https.createServer options, @app 151 | else 152 | @server = http.createServer @app 153 | @server.listen @port 154 | 155 | module.exports.https = new XhrServer 8911, true 156 | module.exports.http = new XhrServer 8912, false 157 | -------------------------------------------------------------------------------- /test/src/nodejs_set_test.coffee: -------------------------------------------------------------------------------- 1 | describe 'XMLHttpRequest', -> 2 | describe '.nodejsSet', -> 3 | beforeEach -> 4 | @xhr = new XMLHttpRequest 5 | @customXhr = new XMLHttpRequest 6 | 7 | describe 'with a httpAgent option', -> 8 | beforeEach -> 9 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 10 | 11 | @customAgent = { custom: 'httpAgent' } 12 | @customXhr.nodejsHttpAgent = @customAgent 13 | 14 | @default = XMLHttpRequest::nodejsHttpAgent 15 | @agent = { mocking: 'httpAgent' } 16 | XMLHttpRequest.nodejsSet httpAgent: @agent 17 | 18 | it 'sets the default nodejsHttpAgent', -> 19 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 20 | expect(@xhr.nodejsHttpAgent).to.equal @agent 21 | 22 | it 'does not interfere with custom nodejsHttpAgent settings', -> 23 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 24 | expect(@customXhr.nodejsHttpAgent).to.equal @customAgent 25 | 26 | afterEach -> 27 | XMLHttpRequest.nodejsSet httpAgent: @default 28 | 29 | describe 'with a httpsAgent option', -> 30 | beforeEach -> 31 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 32 | 33 | @customAgent = { custom: 'httpsAgent' } 34 | @customXhr.nodejsHttpsAgent = @customAgent 35 | 36 | @default = XMLHttpRequest::nodejsHttpsAgent 37 | @agent = { mocking: 'httpsAgent' } 38 | XMLHttpRequest.nodejsSet httpsAgent: @agent 39 | 40 | it 'sets the default nodejsHttpsAgent', -> 41 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 42 | expect(@xhr.nodejsHttpsAgent).to.equal @agent 43 | 44 | it 'does not interfere with custom nodejsHttpsAgent settings', -> 45 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 46 | expect(@customXhr.nodejsHttpsAgent).to.equal @customAgent 47 | 48 | afterEach -> 49 | XMLHttpRequest.nodejsSet httpsAgent: @default 50 | 51 | describe 'with a baseUrl option', -> 52 | beforeEach -> 53 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 54 | 55 | @customBaseUrl = 'http://custom.url/base' 56 | @customXhr.nodejsBaseUrl = @customBaseUrl 57 | 58 | @default = XMLHttpRequest::nodejsBaseUrl 59 | @baseUrl = 'http://localhost/base' 60 | XMLHttpRequest.nodejsSet baseUrl: @baseUrl 61 | 62 | it 'sets the default nodejsBaseUrl', -> 63 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 64 | expect(@xhr.nodejsBaseUrl).to.equal @baseUrl 65 | 66 | it 'does not interfere with custom nodejsBaseUrl settings', -> 67 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 68 | expect(@customXhr.nodejsBaseUrl).to.equal @customBaseUrl 69 | 70 | afterEach -> 71 | XMLHttpRequest.nodejsSet baseUrl: @default 72 | 73 | describe '#nodejsSet', -> 74 | beforeEach -> 75 | @xhr = new XMLHttpRequest 76 | @customXhr = new XMLHttpRequest 77 | 78 | describe 'with a httpAgent option', -> 79 | beforeEach -> 80 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 81 | 82 | @customAgent = { custom: 'httpAgent' } 83 | @customXhr.nodejsSet httpAgent: @customAgent 84 | 85 | it 'sets nodejsHttpAgent on the XHR instance', -> 86 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 87 | expect(@customXhr.nodejsHttpAgent).to.equal @customAgent 88 | 89 | it 'does not interfere with default nodejsHttpAgent settings', -> 90 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 91 | expect(@xhr.nodejsHttpAgent).not.to.equal @customAgent 92 | 93 | describe 'with a httpsAgent option', -> 94 | beforeEach -> 95 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 96 | 97 | @customAgent = { custom: 'httpsAgent' } 98 | @customXhr.nodejsSet httpsAgent: @customAgent 99 | 100 | it 'sets nodejsHttpsAgent on the XHR instance', -> 101 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 102 | expect(@customXhr.nodejsHttpsAgent).to.equal @customAgent 103 | 104 | it 'does not interfere with default nodejsHttpsAgent settings', -> 105 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 106 | expect(@xhr.nodejsHttpsAgent).not.to.equal @customAgent 107 | 108 | describe 'base URL parsing', -> 109 | beforeEach -> 110 | @xhr = new XMLHttpRequest 111 | 112 | describe 'with null baseUrl', -> 113 | beforeEach -> 114 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 115 | @xhr.nodejsSet baseUrl: null 116 | 117 | it 'parses an absolute URL', -> 118 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 119 | parsedUrl = @xhr._parseUrl('http://www.domain.com/path') 120 | expect(parsedUrl).to.be.ok 121 | expect(parsedUrl).to.have.property 'href' 122 | expect(parsedUrl.href).to.equal 'http://www.domain.com/path' 123 | 124 | describe 'with a (protocol, domain, filePath) baseUrl', -> 125 | beforeEach -> 126 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 127 | @xhr.nodejsSet baseUrl: 'https://base.url/dir/file.html' 128 | 129 | it 'parses an absolute URL', -> 130 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 131 | parsedUrl = @xhr._parseUrl('http://www.domain.com/path') 132 | expect(parsedUrl).to.be.ok 133 | expect(parsedUrl).to.have.property 'href' 134 | expect(parsedUrl.href).to.equal 'http://www.domain.com/path' 135 | 136 | it 'parses a path-relative URL', -> 137 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 138 | parsedUrl = @xhr._parseUrl('path/to.js') 139 | expect(parsedUrl).to.be.ok 140 | expect(parsedUrl).to.have.property 'href' 141 | expect(parsedUrl.href).to.equal 'https://base.url/dir/path/to.js' 142 | 143 | it 'parses a path-relative URL with ..', -> 144 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 145 | parsedUrl = @xhr._parseUrl('../path/to.js') 146 | expect(parsedUrl).to.be.ok 147 | expect(parsedUrl).to.have.property 'href' 148 | expect(parsedUrl.href).to.equal 'https://base.url/path/to.js' 149 | 150 | it 'parses a host-relative URL', -> 151 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 152 | parsedUrl = @xhr._parseUrl('/path/to.js') 153 | expect(parsedUrl).to.be.ok 154 | expect(parsedUrl).to.have.property 'href' 155 | expect(parsedUrl.href).to.equal 'https://base.url/path/to.js' 156 | 157 | it 'parses a protocol-relative URL', -> 158 | return unless XMLHttpRequest.nodejsSet # Skip in browsers. 159 | parsedUrl = @xhr._parseUrl('//domain.com/path/to.js') 160 | expect(parsedUrl).to.be.ok 161 | expect(parsedUrl).to.have.property 'href' 162 | expect(parsedUrl.href).to.equal 'https://domain.com/path/to.js' 163 | -------------------------------------------------------------------------------- /test/src/headers_test.coffee: -------------------------------------------------------------------------------- 1 | describe 'XMLHttpRequest', -> 2 | beforeEach -> 3 | @xhr = new XMLHttpRequest 4 | 5 | describe '#setRequestHeader', -> 6 | beforeEach -> 7 | @xhr.open 'POST', 'http://localhost:8912/_/headers' 8 | @xhr.responseType = 'text' 9 | 10 | describe 'with allowed headers', -> 11 | beforeEach -> 12 | @xhr.setRequestHeader 'Authorization', 'lol' 13 | @xhr.setRequestHeader 'User-Agent', 'toaster' 14 | @xhr.setRequestHeader 'X-Answer', '42' 15 | @xhr.setRequestHeader 'X-Header-Name', 'value' 16 | 17 | it 'should send the headers', (done) -> 18 | @xhr.onload = => 19 | expect(@xhr.responseText).to.match(/^\{.*\}$/) 20 | headers = JSON.parse @xhr.responseText 21 | expect(headers).to.have.property 'authorization' 22 | expect(headers['authorization']).to.equal 'lol' 23 | expect(headers).to.have.property 'user-agent' 24 | expect(headers['user-agent']).to.equal 'toaster' 25 | expect(headers).to.have.property 'x-answer' 26 | expect(headers['x-answer']).to.equal '42' 27 | expect(headers).to.have.property 'x-header-name' 28 | expect(headers['x-header-name']).to.equal 'value' 29 | done() 30 | @xhr.send '' 31 | 32 | describe 'with a mix of allowed and forbidden headers', -> 33 | beforeEach -> 34 | @xhr.setRequestHeader 'Authorization', 'lol' 35 | @xhr.setRequestHeader 'Proxy-Authorization', 'evil:kitten' 36 | @xhr.setRequestHeader 'Sec-Breach', 'yes please' 37 | @xhr.setRequestHeader 'Host', 'www.google.com' 38 | @xhr.setRequestHeader 'Origin', 'https://www.google.com' 39 | @xhr.setRequestHeader 'X-Answer', '42' 40 | 41 | it 'should only send the allowed headers', (done) -> 42 | @xhr.onloadend = => 43 | expect(@xhr.responseText).to.match(/^\{.*\}$/) 44 | headers = JSON.parse @xhr.responseText 45 | expect(headers).to.have.property 'authorization' 46 | expect(headers['authorization']).to.equal 'lol' 47 | expect(headers).not.to.have.property 'proxy-authorization' 48 | expect(headers).not.to.have.property 'sec-breach' 49 | expect(headers['origin']).not.to.match /www\.google\.com/ 50 | expect(headers['host']).not.to.match /www\.google\.com/ 51 | expect(headers).to.have.property 'x-answer' 52 | expect(headers['x-answer']).to.equal '42' 53 | done() 54 | @xhr.send '' 55 | 56 | describe 'with repeated headers', -> 57 | beforeEach -> 58 | @xhr.setRequestHeader 'Authorization', 'trol' 59 | @xhr.setRequestHeader 'Authorization', 'lol' 60 | @xhr.setRequestHeader 'Authorization', 'lol' 61 | @xhr.setRequestHeader 'X-Answer', '42' 62 | 63 | it 'should only send the allowed headers', (done) -> 64 | _done = false 65 | @xhr.onreadystatechange = => 66 | return if _done or @xhr.readyState isnt XMLHttpRequest.DONE 67 | _done = true 68 | expect(@xhr.responseText).to.match(/^\{.*\}$/) 69 | headers = JSON.parse @xhr.responseText 70 | expect(headers).to.have.property 'authorization' 71 | expect(headers['authorization']).to.equal 'trol, lol, lol' 72 | expect(headers).to.have.property 'x-answer' 73 | expect(headers['x-answer']).to.equal '42' 74 | done() 75 | @xhr.send '' 76 | 77 | describe 'with no headers', -> 78 | beforeEach -> 79 | @xhr.open 'POST', 'http://localhost:8912/_/headers' 80 | @xhr.responseType = 'text' 81 | 82 | it 'should set the protected headers correctly', (done) -> 83 | @xhr.onload = => 84 | expect(@xhr.responseText).to.match(/^\{.*\}$/) 85 | headers = JSON.parse @xhr.responseText 86 | expect(headers).to.have.property 'connection' 87 | expect(headers['connection']).to.equal 'keep-alive' 88 | expect(headers).to.have.property 'host' 89 | expect(headers['host']).to.equal 'localhost:8912' 90 | expect(headers).to.have.property 'user-agent' 91 | expect(headers['user-agent']).to.match(/^Mozilla\//) 92 | done() 93 | @xhr.send '' 94 | 95 | describe '#getResponseHeader', -> 96 | beforeEach -> 97 | @xhr.open 'POST', 'http://localhost:8912/_/get_headers' 98 | @headerJson = 99 | ''' 100 | {"Accept-Ranges": "bytes", 101 | "Content-Type": "application/xhr2; charset=utf-1337", 102 | "Set-Cookie": "UserID=JohnDoe; Max-Age=3600; Version=1", 103 | "X-Header": "one, more, value"} 104 | ''' 105 | 106 | it 'returns accessible headers', (done) -> 107 | @xhr.onloadend = => 108 | expect(@xhr.getResponseHeader('AccEPt-RANgeS')).to.equal 'bytes' 109 | expect(@xhr.getResponseHeader('content-Type')).to. 110 | equal 'application/xhr2; charset=utf-1337' 111 | expect(@xhr.getResponseHeader('X-Header')).to.equal "one, more, value" 112 | done() 113 | @xhr.send @headerJson 114 | 115 | it 'returns null for private headers', (done) -> 116 | @xhr.onloadend = => 117 | expect(@xhr.getResponseHeader('set-cookie')).to.equal null 118 | done() 119 | @xhr.send @headerJson 120 | 121 | it 'returns headers when the XHR enters HEADERS_RECEIVED', (done) -> 122 | _done = false 123 | @xhr.onreadystatechange = => 124 | return if _done or @xhr.readyState isnt XMLHttpRequest.HEADERS_RECEIVED 125 | _done = true 126 | expect(@xhr.getResponseHeader('AccEPt-RANgeS')).to.equal 'bytes' 127 | done() 128 | @xhr.send @headerJson 129 | 130 | describe '#getAllResponseHeaders', -> 131 | beforeEach -> 132 | @xhr.open 'POST', 'http://localhost:8912/_/get_headers' 133 | @headerJson = 134 | ''' 135 | {"Accept-Ranges": "bytes", 136 | "Content-Type": "application/xhr2; charset=utf-1337", 137 | "Set-Cookie": "UserID=JohnDoe; Max-Age=3600; Version=1", 138 | "X-Header": "one, more, value"} 139 | ''' 140 | 141 | it 'contains accessible headers', (done) -> 142 | @xhr.onloadend = => 143 | headers = @xhr.getAllResponseHeaders() 144 | expect(headers).to.match(/(\A|\r\n)accept-ranges: bytes(\r\n|\Z)/mi) 145 | expect(headers).to.match( 146 | /(\A|\r\n)content-type: application\/xhr2; charset=utf-1337(\r\n|\Z)/mi) 147 | expect(headers).to.match(/(\A|\r\n)X-Header: one, more, value(\r\n|\Z)/mi) 148 | done() 149 | @xhr.send @headerJson 150 | 151 | it 'does not contain private headers', (done) -> 152 | @xhr.onloadend = => 153 | headers = @xhr.getAllResponseHeaders() 154 | expect(headers).not.to.match(/(\A|\r\n)set-cookie:/mi) 155 | done() 156 | @xhr.send @headerJson 157 | 158 | it 'returns headers when the XHR enters HEADERS_RECEIVED', (done) -> 159 | _done = false 160 | @xhr.onreadystatechange = => 161 | return if _done or @xhr.readyState isnt XMLHttpRequest.HEADERS_RECEIVED 162 | _done = true 163 | headers = @xhr.getAllResponseHeaders() 164 | expect(headers).to.match(/(\A|\r\n)accept-ranges: bytes(\r\n|\Z)/mi) 165 | done() 166 | @xhr.send @headerJson 167 | 168 | 169 | -------------------------------------------------------------------------------- /test/src/events_test.coffee: -------------------------------------------------------------------------------- 1 | describe 'XMLHttpRequest', -> 2 | beforeEach -> 3 | @xhr = new XMLHttpRequest 4 | @dripUrl = 'http://localhost:8912/_/drip' 5 | @dripJson = drips: 3, size: 1000, ms: 50, length: true 6 | 7 | describe 'level 2 events', -> 8 | beforeEach -> 9 | @events = [] 10 | @endFired = false 11 | @events.check = -> null # replaced by tests 12 | @xhr.addEventListener 'loadstart', (event) => 13 | expect(event.type).to.equal 'loadstart' 14 | expect(@endFired).to.equal false 15 | @events.push event 16 | @xhr.addEventListener 'progress', (event) => 17 | expect(event.type).to.equal 'progress' 18 | expect(@endFired).to.equal false 19 | @events.push event 20 | @xhr.addEventListener 'load', (event) => 21 | expect(event.type).to.equal 'load' 22 | expect(@endFired).to.equal false 23 | @events.push event 24 | @xhr.addEventListener 'loadend', (event) => 25 | expect(event.type).to.equal 'loadend' 26 | expect(@endFired).to.equal false 27 | @endFired = 'loadend already fired' 28 | @events.push event 29 | @events.check() 30 | @xhr.addEventListener 'error', (event) => 31 | expect(event.type).to.equal 'error' 32 | expect(@endFired).to.equal false 33 | @events.push event 34 | @xhr.addEventListener 'abort', (event) => 35 | expect(event.type).to.equal 'abort' 36 | expect(@endFired).to.equal false 37 | @events.push event 38 | 39 | describe 'for a successful fetch with Content-Length set', -> 40 | beforeEach -> 41 | @xhr.open 'POST', @dripUrl 42 | @xhr.send JSON.stringify(@dripJson) 43 | 44 | it 'events have the correct target', (done) -> 45 | @events.check = => 46 | for event in @events 47 | expect(event.target).to.equal @xhr 48 | done() 49 | 50 | it 'events have the correct bubbling setup', (done) -> 51 | @events.check = => 52 | for event in @events 53 | expect(event.bubbles).to.equal false 54 | expect(event.cancelable).to.equal false 55 | done() 56 | 57 | it 'events have the correct progress info', (done) -> 58 | @events.check = => 59 | for event in @events 60 | switch event.type 61 | when 'loadstart' 62 | expect(event.loaded).to.equal 0 63 | expect(event.lengthComputable).to.equal false 64 | expect(event.total).to.equal 0 65 | when 'load', 'loadend' 66 | expect(event.loaded).to.equal 3000 67 | expect(event.lengthComputable).to.equal true 68 | expect(event.total).to.equal 3000 69 | when 'progress' 70 | if event.lengthComputable 71 | expect(event.loaded).to.be.gte 0 72 | expect(event.loaded).to.be.lte 3000 73 | expect(event.total).to.equal 3000 74 | else 75 | expect(event.loaded).to.be.gte 0 76 | expect(event.total).to.equal 0 77 | done() 78 | 79 | it 'events include at least one intermediate progress event', (done) -> 80 | @events.check = => 81 | found = 'no suitable progress event emitted' 82 | for event in @events 83 | continue unless event.type is 'progress' 84 | continue unless event.loaded > 0 85 | continue unless event.loaded < event.total 86 | found = true 87 | expect(found).to.equal true 88 | done() 89 | 90 | describe 'for a successful fetch without Content-Length set', -> 91 | beforeEach -> 92 | @xhr.open 'POST', @dripUrl 93 | @dripJson.length = false 94 | @xhr.send JSON.stringify(@dripJson) 95 | 96 | it 'events have the correct progress info', (done) -> 97 | @events.check = => 98 | for event in @events 99 | expect(event.lengthComputable).to.equal false 100 | expect(event.total).to.equal 0 101 | switch event.type 102 | when 'loadstart' 103 | expect(event.loaded).to.equal 0 104 | when 'load', 'loadend' 105 | expect(event.loaded).to.equal 3000 106 | when 'progress' 107 | expect(event.loaded).to.be.gte 0 108 | done() 109 | 110 | it 'events include at least one intermediate progress event', (done) -> 111 | @events.check = => 112 | found = 'no suitable progress event emitted' 113 | for event in @events 114 | continue unless event.type is 'progress' 115 | continue if event.loaded is 0 116 | continue if event.loaded is 3000 117 | found = true 118 | expect(found).to.equal true 119 | done() 120 | 121 | describe 'for a network error due to bad DNS', (done) -> 122 | beforeEach -> 123 | @xhr.open 'GET', 'https://broken.to.cause.an.xhrnetworkerror.com' 124 | @xhr.send() 125 | 126 | it 'no progress or load is emitted', (done) -> 127 | @events.check = => 128 | for event in @events 129 | expect(event.type).not.to.equal 'load' 130 | expect(event.type).not.to.equal 'progress' 131 | done() 132 | 133 | it 'events include an error event', (done) -> 134 | @events.check = => 135 | found = 'no suitable error emitted' 136 | for event in @events 137 | continue unless event.type is 'error' 138 | found = true 139 | expect(found).to.equal true 140 | done() 141 | 142 | describe 'readystatechange', -> 143 | beforeEach -> 144 | @events = [] 145 | @states = [] 146 | @doneFired = false 147 | @events.check = -> null # replaced by tests 148 | @xhr.addEventListener 'readystatechange', (event) => 149 | expect(event.type).to.equal 'readystatechange' 150 | expect(@doneFired).to.equal false 151 | @events.push event 152 | @states.push event.target.readyState 153 | if event.target.readyState is XMLHttpRequest.DONE 154 | @doneFired = 'DONE already fired' 155 | @events.check() 156 | 157 | describe 'for a successful fetch with Content-Length set', -> 158 | beforeEach -> 159 | @xhr.open 'POST', @dripUrl 160 | @xhr.send JSON.stringify(@dripJson) 161 | 162 | it 'events have the correct target', (done) -> 163 | @events.check = => 164 | for event in @events 165 | expect(event.target).to.equal @xhr 166 | done() 167 | 168 | it 'events have the correct bubbling setup', (done) -> 169 | @events.check = => 170 | for event in @events 171 | expect(event.bubbles).to.equal false 172 | expect(event.cancelable).to.equal false 173 | done() 174 | 175 | it 'events states are in the correct order', (done) -> 176 | @events.check = => 177 | expect(@states).to.deep.equal [XMLHttpRequest.OPENED, 178 | XMLHttpRequest.HEADERS_RECEIVED, 179 | XMLHttpRequest.LOADING, XMLHttpRequest.DONE] 180 | done() 181 | 182 | describe 'for a successful fetch without Content-Length set', -> 183 | beforeEach -> 184 | @xhr.open 'POST', @dripUrl 185 | @dripJson.length = false 186 | @xhr.send JSON.stringify(@dripJson) 187 | 188 | it 'events states are in the correct order', (done) -> 189 | @events.check = => 190 | expect(@states).to.deep.equal [XMLHttpRequest.OPENED, 191 | XMLHttpRequest.HEADERS_RECEIVED, XMLHttpRequest.LOADING, 192 | XMLHttpRequest.DONE] 193 | done() 194 | 195 | describe 'for a network error due to bad DNS', (done) -> 196 | beforeEach -> 197 | @xhr.open 'GET', 'https://broken.to.cause.an.xhrnetworkerror.com' 198 | @xhr.send() 199 | 200 | it 'events states are in the correct order', (done) -> 201 | @events.check = => 202 | expect(@states).to.deep.equal [XMLHttpRequest.OPENED, 203 | XMLHttpRequest.DONE] 204 | done() 205 | -------------------------------------------------------------------------------- /src/001-xml_http_request.coffee: -------------------------------------------------------------------------------- 1 | # This file's name is set up in such a way that it will always show up second 2 | # in the list of files given to coffee --join, so it can use the 3 | # XMLHttpRequestEventTarget definition and so that the other files can assume 4 | # that XMLHttpRequest was already defined. 5 | 6 | http = require 'http' 7 | https = require 'https' 8 | os = require 'os' 9 | url = require 'url' 10 | 11 | # The ECMAScript HTTP API. 12 | # 13 | # @see http://www.w3.org/TR/XMLHttpRequest/#introduction 14 | class XMLHttpRequest extends XMLHttpRequestEventTarget 15 | # Creates a new request. 16 | # 17 | # @param {Object} options one or more of the options below 18 | # @option options {Boolean} anon if true, the request's anonymous flag 19 | # will be set 20 | # @see http://www.w3.org/TR/XMLHttpRequest/#constructors 21 | # @see http://www.w3.org/TR/XMLHttpRequest/#anonymous-flag 22 | constructor: (options) -> 23 | super() 24 | @onreadystatechange = null 25 | 26 | @_anonymous = options and options.anon 27 | 28 | @readyState = XMLHttpRequest.UNSENT 29 | @response = null 30 | @responseText = '' 31 | @responseType = '' 32 | @responseURL = '' 33 | @status = 0 34 | @statusText = '' 35 | @timeout = 0 36 | @upload = new XMLHttpRequestUpload @ 37 | 38 | @_method = null # String 39 | @_url = null # Return value of url.parse() 40 | @_sync = false 41 | @_headers = null # Object 42 | @_loweredHeaders = null # Object 43 | @_mimeOverride = null 44 | @_request = null # http.ClientRequest 45 | @_response = null # http.ClientResponse 46 | @_responseParts = null # Array 47 | @_responseHeaders = null # Object 48 | @_aborting = null 49 | @_error = null 50 | @_loadedBytes = 0 51 | @_totalBytes = 0 52 | @_lengthComputable = false 53 | 54 | # @property {function(ProgressEvent)} DOM level 0-style handler for the 55 | # 'readystatechange' event 56 | onreadystatechange: null 57 | 58 | # @property {Number} the current state of the XHR object 59 | # @see http://www.w3.org/TR/XMLHttpRequest/#states 60 | readyState: null 61 | 62 | # @property {String, ArrayBuffer, Buffer, Object} processed XHR response 63 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-response-attribute 64 | response: null 65 | 66 | # @property {String} response string, if responseType is '' or 'text' 67 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-responsetext-attribute 68 | responseText: null 69 | 70 | # @property {String} sets the parsing method for the XHR response 71 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-responsetype-attribute 72 | responseType: null 73 | 74 | # @property {Number} the HTTP 75 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-status-attribute 76 | status: null 77 | 78 | # @property {Number} milliseconds to wait for the request to complete 79 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-timeout-attribute 80 | timeout: null 81 | 82 | # @property {XMLHttpRequestUpload} the associated upload information 83 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-upload-attribute 84 | upload: null 85 | 86 | # Sets the XHR's method, URL, synchronous flag, and authentication params. 87 | # 88 | # @param {String} method the HTTP method to be used 89 | # @param {String} url the URL that the request will be made to 90 | # @param {?Boolean} async if false, the XHR should be processed 91 | # synchronously; true by default 92 | # @param {?String} user the user credential to be used in HTTP basic 93 | # authentication 94 | # @param {?String} password the password credential to be used in HTTP basic 95 | # authentication 96 | # @return {undefined} undefined 97 | # @throw {SecurityError} method is not one of the allowed methods 98 | # @throw {SyntaxError} urlString is not a valid URL 99 | # @throw {Error} the URL contains an unsupported protocol; the supported 100 | # protocols are file, http and https 101 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-open()-method 102 | open: (method, url, async, user, password) -> 103 | method = method.toUpperCase() 104 | if method of @_restrictedMethods 105 | throw new SecurityError "HTTP method #{method} is not allowed in XHR" 106 | 107 | xhrUrl = @_parseUrl url 108 | async = true if async is undefined 109 | 110 | switch @readyState 111 | when XMLHttpRequest.UNSENT, XMLHttpRequest.OPENED, XMLHttpRequest.DONE 112 | # Nothing to do here. 113 | null 114 | when XMLHttpRequest.HEADERS_RECEIVED, XMLHttpRequest.LOADING 115 | # TODO(pwnall): terminate abort(), terminate send() 116 | null 117 | 118 | @_method = method 119 | @_url = xhrUrl 120 | @_sync = !async 121 | @_headers = {} 122 | @_loweredHeaders = {} 123 | @_mimeOverride = null 124 | @_setReadyState XMLHttpRequest.OPENED 125 | @_request = null 126 | @_response = null 127 | @status = 0 128 | @statusText = '' 129 | @_responseParts = [] 130 | @_responseHeaders = null 131 | @_loadedBytes = 0 132 | @_totalBytes = 0 133 | @_lengthComputable = false 134 | undefined 135 | 136 | # Appends a header to the list of author request headers. 137 | # 138 | # @param {String} name the HTTP header name 139 | # @param {String} value the HTTP header value 140 | # @return {undefined} undefined 141 | # @throw {InvalidStateError} readyState is not OPENED 142 | # @throw {SyntaxError} name is not a valid HTTP header name or value is not 143 | # a valid HTTP header value 144 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-setrequestheader()-method 145 | setRequestHeader: (name, value) -> 146 | unless @readyState is XMLHttpRequest.OPENED 147 | throw new InvalidStateError "XHR readyState must be OPENED" 148 | 149 | loweredName = name.toLowerCase() 150 | if @_restrictedHeaders[loweredName] or /^sec\-/.test(loweredName) or 151 | /^proxy-/.test(loweredName) 152 | console.warn "Refused to set unsafe header \"#{name}\"" 153 | return undefined 154 | 155 | value = value.toString() 156 | if loweredName of @_loweredHeaders 157 | # Combine value with the existing header value. 158 | name = @_loweredHeaders[loweredName] 159 | @_headers[name] = @_headers[name] + ', ' + value 160 | else 161 | # New header. 162 | @_loweredHeaders[loweredName] = name 163 | @_headers[name] = value 164 | 165 | undefined 166 | 167 | # Initiates the request. 168 | # 169 | # @param {?String, ?ArrayBufferView} data the data to be sent; ignored for 170 | # GET and HEAD requests 171 | # @return {undefined} undefined 172 | # @throw {InvalidStateError} readyState is not OPENED 173 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-send()-method 174 | send: (data) -> 175 | unless @readyState is XMLHttpRequest.OPENED 176 | throw new InvalidStateError "XHR readyState must be OPENED" 177 | 178 | if @_request 179 | throw new InvalidStateError "send() already called" 180 | 181 | switch @_url.protocol 182 | when 'file:' 183 | @_sendFile data 184 | when 'http:', 'https:' 185 | @_sendHttp data 186 | else 187 | throw new NetworkError "Unsupported protocol #{@_url.protocol}" 188 | 189 | undefined 190 | 191 | # Cancels the network activity performed by this request. 192 | # 193 | # @return {undefined} undefined 194 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-abort()-method 195 | abort: -> 196 | return unless @_request 197 | 198 | @_request.abort() 199 | @_setError() 200 | @_dispatchProgress 'abort' 201 | @_dispatchProgress 'loadend' 202 | undefined 203 | 204 | # Returns a header value in the HTTP response for this XHR. 205 | # 206 | # @param {String} name case-insensitive HTTP header name 207 | # @return {?String} value the value of the header whose name matches the 208 | # given name, or null if there is no such header 209 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-getresponseheader()-method 210 | getResponseHeader: (name) -> 211 | return null unless @_responseHeaders 212 | 213 | loweredName = name.toLowerCase() 214 | if loweredName of @_responseHeaders 215 | @_responseHeaders[loweredName] 216 | else 217 | null 218 | 219 | # Returns all the HTTP headers in this XHR's response. 220 | # 221 | # @return {String} header lines separated by CR LF, where each header line 222 | # has the name and value separated by a ": " (colon, space); the empty 223 | # string is returned if the headers are not available 224 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-getallresponseheaders()-method 225 | getAllResponseHeaders: -> 226 | return '' unless @_responseHeaders 227 | 228 | lines = ("#{name}: #{value}" for name, value of @_responseHeaders) 229 | lines.join "\r\n" 230 | 231 | # Overrides the Content-Type 232 | # 233 | # @return {undefined} undefined 234 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-overridemimetype()-method 235 | overrideMimeType: (newMimeType) -> 236 | if @readyState is XMLHttpRequest.LOADING or 237 | @readyState is XMLHttpRequest.DONE 238 | throw new InvalidStateError( 239 | "overrideMimeType() not allowed in LOADING or DONE") 240 | 241 | @_mimeOverride = newMimeType.toLowerCase() 242 | undefined 243 | 244 | # Network configuration not exposed in the XHR API. 245 | # 246 | # Although the XMLHttpRequest specification calls itself "ECMAScript HTTP", 247 | # it assumes that requests are always performed in the context of a browser 248 | # application, where some network parameters are set by the browser user and 249 | # should not be modified by Web applications. This API provides access to 250 | # these network parameters. 251 | # 252 | # NOTE: this is not in the XMLHttpRequest API, and will not work in 253 | # browsers. It is a stable node-xhr2 API. 254 | # 255 | # @param {Object} options one or more of the options below 256 | # @option options {?http.Agent} httpAgent the value for the nodejsHttpAgent 257 | # property (the agent used for HTTP requests) 258 | # @option options {?https.Agent} httpsAgent the value for the 259 | # nodejsHttpsAgent property (the agent used for HTTPS requests) 260 | # @return {undefined} undefined 261 | nodejsSet: (options) -> 262 | if 'httpAgent' of options 263 | @nodejsHttpAgent = options.httpAgent 264 | if 'httpsAgent' of options 265 | @nodejsHttpsAgent = options.httpsAgent 266 | if 'baseUrl' of options 267 | baseUrl = options.baseUrl 268 | unless baseUrl is null 269 | parsedUrl = url.parse baseUrl, false, true 270 | unless parsedUrl.protocol 271 | throw new SyntaxError("baseUrl must be an absolute URL") 272 | @nodejsBaseUrl = baseUrl 273 | 274 | undefined 275 | 276 | # Default settings for the network configuration not exposed in the XHR API. 277 | # 278 | # NOTE: this is not in the XMLHttpRequest API, and will not work in 279 | # browsers. It is a stable node-xhr2 API. 280 | # 281 | # @param {Object} options one or more of the options below 282 | # @option options {?http.Agent} httpAgent the default value for the 283 | # nodejsHttpAgent property (the agent used for HTTP requests) 284 | # @option options {https.Agent} httpsAgent the default value for the 285 | # nodejsHttpsAgent property (the agent used for HTTPS requests) 286 | # @return {undefined} undefined 287 | # @see XMLHttpRequest.nodejsSet 288 | @nodejsSet: (options) -> 289 | # "this" will be set to XMLHttpRequest.prototype, so the instance nodejsSet 290 | # operates on default property values. 291 | XMLHttpRequest::nodejsSet options 292 | 293 | undefined 294 | 295 | # readyState value before XMLHttpRequest#open() is called 296 | UNSENT: 0 297 | # readyState value before XMLHttpRequest#open() is called 298 | @UNSENT: 0 299 | 300 | # readyState value after XMLHttpRequest#open() is called, and before 301 | # XMLHttpRequest#send() is called; XMLHttpRequest#setRequestHeader() can be 302 | # called in this state 303 | OPENED: 1 304 | # readyState value after XMLHttpRequest#open() is called, and before 305 | # XMLHttpRequest#send() is called; XMLHttpRequest#setRequestHeader() can be 306 | # called in this state 307 | @OPENED: 1 308 | 309 | # readyState value after redirects have been followed and the HTTP headers of 310 | # the final response have been received 311 | HEADERS_RECEIVED: 2 312 | # readyState value after redirects have been followed and the HTTP headers of 313 | # the final response have been received 314 | @HEADERS_RECEIVED: 2 315 | 316 | # readyState value when the response entity body is being received 317 | LOADING: 3 318 | # readyState value when the response entity body is being received 319 | @LOADING: 3 320 | 321 | # readyState value after the request has been completely processed 322 | DONE: 4 323 | # readyState value after the request has been completely processed 324 | @DONE: 4 325 | 326 | # @property {http.Agent} the agent option passed to HTTP requests 327 | # 328 | # NOTE: this is not in the XMLHttpRequest API, and will not work in browsers. 329 | # It is a stable node-xhr2 API that is useful for testing & going through 330 | # web-proxies. 331 | nodejsHttpAgent: http.globalAgent 332 | 333 | # @property {https.Agent} the agent option passed to HTTPS requests 334 | # 335 | # NOTE: this is not in the XMLHttpRequest API, and will not work in browsers. 336 | # It is a stable node-xhr2 API that is useful for testing & going through 337 | # web-proxies. 338 | nodejsHttpsAgent: https.globalAgent 339 | 340 | # @property {String} the base URL that relative URLs get resolved to 341 | # 342 | # NOTE: this is not in the XMLHttpRequest API, and will not work in browsers. 343 | # Its browser equivalent is the base URL of the document associated with the 344 | # Window object. It is a stable node-xhr2 API provided for libraries such as 345 | # Angular Universal. 346 | nodejsBaseUrl: null 347 | 348 | # HTTP methods that are disallowed in the XHR spec. 349 | # 350 | # @private 351 | # @see Step 6 in http://www.w3.org/TR/XMLHttpRequest/#the-open()-method 352 | _restrictedMethods: 353 | CONNECT: true 354 | TRACE: true 355 | TRACK: true 356 | 357 | # HTTP request headers that are disallowed in the XHR spec. 358 | # 359 | # @private 360 | # @see Step 5 in 361 | # http://www.w3.org/TR/XMLHttpRequest/#the-setrequestheader()-method 362 | _restrictedHeaders: 363 | 'accept-charset': true 364 | 'accept-encoding': true 365 | 'access-control-request-headers': true 366 | 'access-control-request-method': true 367 | connection: true 368 | 'content-length': true 369 | cookie: true 370 | cookie2: true 371 | date: true 372 | dnt: true 373 | expect: true 374 | host: true 375 | 'keep-alive': true 376 | origin: true 377 | referer: true 378 | te: true 379 | trailer: true 380 | 'transfer-encoding': true 381 | upgrade: true 382 | via: true 383 | 384 | # HTTP response headers that should not be exposed according to the XHR spec. 385 | # 386 | # @private 387 | # @see Step 3 in 388 | # http://www.w3.org/TR/XMLHttpRequest/#the-getresponseheader()-method 389 | _privateHeaders: 390 | 'set-cookie': true 391 | 'set-cookie2': true 392 | 393 | # The default value of the User-Agent header. 394 | _userAgent: "Mozilla/5.0 (#{os.type()} #{os.arch()}) " + 395 | "node.js/#{process.versions.node} v8/#{process.versions.v8}" 396 | 397 | # Sets the readyState property and fires the readystatechange event. 398 | # 399 | # @private 400 | # @param {Number} newReadyState the new value of readyState 401 | # @return {undefined} undefined 402 | _setReadyState: (newReadyState) -> 403 | @readyState = newReadyState 404 | event = new ProgressEvent 'readystatechange' 405 | @dispatchEvent event 406 | undefined 407 | 408 | # XMLHttpRequest#send() implementation for the file: protocol. 409 | # 410 | # @private 411 | _sendFile: -> 412 | unless @_url.method is 'GET' 413 | throw new NetworkError 'The file protocol only supports GET' 414 | 415 | throw new Error "Protocol file: not implemented" 416 | 417 | # XMLHttpRequest#send() implementation for the http: and https: protocols. 418 | # 419 | # @private 420 | # This method sets the instance variables and calls _sendHxxpRequest(), which 421 | # is responsible for building a node.js request and firing it off. The code 422 | # in _sendHxxpRequest() is separated off so it can be reused when handling 423 | # redirects. 424 | # 425 | # @see http://www.w3.org/TR/XMLHttpRequest/#infrastructure-for-the-send()-method 426 | _sendHttp: (data) -> 427 | if @_sync 428 | throw new Error "Synchronous XHR processing not implemented" 429 | 430 | if data? and (@_method is 'GET' or @_method is 'HEAD') 431 | console.warn "Discarding entity body for #{@_method} requests" 432 | data = null 433 | else 434 | # Send Content-Length: 0 435 | data or= '' 436 | 437 | # NOTE: this is called before finalizeHeaders so that the uploader can 438 | # figure out Content-Length and Content-Type. 439 | @upload._setData data 440 | @_finalizeHeaders() 441 | 442 | @_sendHxxpRequest() 443 | undefined 444 | 445 | # Sets up and fires off a HTTP/HTTPS request using the node.js API. 446 | # 447 | # @private 448 | # This method contains the bulk of the XMLHttpRequest#send() implementation, 449 | # and is also used to issue new HTTP requests when handling HTTP redirects. 450 | # 451 | # @see http://www.w3.org/TR/XMLHttpRequest/#infrastructure-for-the-send()-method 452 | _sendHxxpRequest: -> 453 | if @_url.protocol is 'http:' 454 | hxxp = http 455 | agent = @nodejsHttpAgent 456 | else 457 | hxxp = https 458 | agent = @nodejsHttpsAgent 459 | 460 | request = hxxp.request 461 | hostname: @_url.hostname, port: @_url.port, path: @_url.path, 462 | auth: @_url.auth, method: @_method, headers: @_headers, agent: agent 463 | @_request = request 464 | if @timeout 465 | request.setTimeout @timeout, => @_onHttpTimeout request 466 | request.on 'response', (response) => @_onHttpResponse request, response 467 | request.on 'error', (error) => @_onHttpRequestError request, error 468 | @upload._startUpload request 469 | if @_request is request # An http error might have already fired. 470 | @_dispatchProgress 'loadstart' 471 | 472 | undefined 473 | 474 | # Fills in the restricted HTTP headers with default values. 475 | # 476 | # This is called right before the HTTP request is sent off. 477 | # 478 | # @private 479 | # @return {undefined} undefined 480 | _finalizeHeaders: -> 481 | @_headers['Connection'] = 'keep-alive' 482 | @_headers['Host'] = @_url.host 483 | if @_anonymous 484 | @_headers['Referer'] = 'about:blank' 485 | @_headers['User-Agent'] ||= @_userAgent 486 | @upload._finalizeHeaders @_headers, @_loweredHeaders 487 | undefined 488 | 489 | 490 | # Called when the headers of an HTTP response have been received. 491 | # 492 | # @private 493 | # @param {http.ClientRequest} request the node.js ClientRequest instance that 494 | # produced this response 495 | # @param {http.ClientResponse} response the node.js ClientResponse instance 496 | # passed to 497 | _onHttpResponse: (request, response) -> 498 | return unless @_request is request 499 | 500 | # Transparent redirection handling. 501 | switch response.statusCode 502 | when 301, 302, 303, 307, 308 503 | @_url = @_parseUrl response.headers['location'] 504 | @_method = 'GET' 505 | if 'content-type' of @_loweredHeaders 506 | delete @_headers[@_loweredHeaders['content-type']] 507 | delete @_loweredHeaders['content-type'] 508 | # XMLHttpRequestUpload#_finalizeHeaders() sets Content-Type directly. 509 | if 'Content-Type' of @_headers 510 | delete @_headers['Content-Type'] 511 | # Restricted headers can't be set by the user, no need to check 512 | # loweredHeaders. 513 | delete @_headers['Content-Length'] 514 | 515 | @upload._reset() 516 | @_finalizeHeaders() 517 | @_sendHxxpRequest() 518 | return 519 | 520 | @_response = response 521 | @_response.on 'data', (data) => @_onHttpResponseData response, data 522 | @_response.on 'end', => @_onHttpResponseEnd response 523 | @_response.on 'close', => @_onHttpResponseClose response 524 | 525 | @responseURL = @_url.href.split('#')[0] 526 | @status = @_response.statusCode 527 | @statusText = http.STATUS_CODES[@status] 528 | @_parseResponseHeaders response 529 | 530 | if lengthString = @_responseHeaders['content-length'] 531 | @_totalBytes = parseInt(lengthString) 532 | @_lengthComputable = true 533 | else 534 | @_lengthComputable = false 535 | 536 | @_setReadyState XMLHttpRequest.HEADERS_RECEIVED 537 | 538 | # Called when some data has been received on a HTTP connection. 539 | # 540 | # @private 541 | # @param {http.ClientResponse} response the node.js ClientResponse instance 542 | # that fired this event 543 | # @param {String, Buffer} data the data that has been received 544 | _onHttpResponseData: (response, data) -> 545 | return unless @_response is response 546 | 547 | @_responseParts.push data 548 | @_loadedBytes += data.length 549 | 550 | if @readyState isnt XMLHttpRequest.LOADING 551 | @_setReadyState XMLHttpRequest.LOADING 552 | @_dispatchProgress 'progress' 553 | 554 | # Called when the HTTP request finished processing. 555 | # 556 | # @private 557 | # @param {http.ClientResponse} response the node.js ClientResponse instance 558 | # that fired this event 559 | _onHttpResponseEnd: (response) -> 560 | return unless @_response is response 561 | 562 | @_parseResponse() 563 | 564 | @_request = null 565 | @_response = null 566 | @_setReadyState XMLHttpRequest.DONE 567 | @_dispatchProgress 'load' 568 | @_dispatchProgress 'loadend' 569 | 570 | # Called when the underlying HTTP connection was closed prematurely. 571 | # 572 | # If this method is called, it will be called after or instead of 573 | # onHttpResponseEnd. 574 | # 575 | # @private 576 | # @param {http.ClientResponse} response the node.js ClientResponse instance 577 | # that fired this event 578 | _onHttpResponseClose: (response) -> 579 | return unless @_response is response 580 | 581 | request = @_request 582 | @_setError() 583 | request.abort() 584 | @_setReadyState XMLHttpRequest.DONE 585 | @_dispatchProgress 'error' 586 | @_dispatchProgress 'loadend' 587 | 588 | # Called when the timeout set on the HTTP socket expires. 589 | # 590 | # @private 591 | # @param {http.ClientRequest} request the node.js ClientRequest instance that 592 | # fired this event 593 | _onHttpTimeout: (request) -> 594 | return unless @_request is request 595 | 596 | @_setError() 597 | request.abort() 598 | @_setReadyState XMLHttpRequest.DONE 599 | @_dispatchProgress 'timeout' 600 | @_dispatchProgress 'loadend' 601 | 602 | # Called when something wrong happens on the HTTP socket 603 | # 604 | # @private 605 | # @param {http.ClientRequest} request the node.js ClientRequest instance that 606 | # fired this event 607 | # @param {Error} error emitted exception 608 | _onHttpRequestError: (request, error) -> 609 | return unless @_request is request 610 | 611 | @_setError() 612 | request.abort() 613 | @_setReadyState XMLHttpRequest.DONE 614 | @_dispatchProgress 'error' 615 | @_dispatchProgress 'loadend' 616 | 617 | # Fires an XHR progress event. 618 | # 619 | # @private 620 | # @param {String} eventType one of the XHR progress event types, such as 621 | # 'load' and 'progress' 622 | _dispatchProgress: (eventType) -> 623 | event = new ProgressEvent eventType 624 | event.lengthComputable = @_lengthComputable 625 | event.loaded = @_loadedBytes 626 | event.total = @_totalBytes 627 | @dispatchEvent event 628 | undefined 629 | 630 | # Sets up the XHR to reflect the fact that an error has occurred. 631 | # 632 | # The possible errors are a network error, a timeout, or an abort. 633 | # 634 | # @private 635 | _setError: -> 636 | @_request = null 637 | @_response = null 638 | @_responseHeaders = null 639 | @_responseParts = null 640 | undefined 641 | 642 | # Parses a request URL string. 643 | # 644 | # @private 645 | # This method is a thin wrapper around url.parse() that normalizes HTTP 646 | # user/password credentials. It is used to parse the URL string passed to 647 | # XMLHttpRequest#open() and the URLs in the Location headers of HTTP redirect 648 | # responses. 649 | # 650 | # @param {String} urlString the URL to be parsed 651 | # @return {Object} parsed URL 652 | _parseUrl: (urlString) -> 653 | if @nodejsBaseUrl is null 654 | absoluteUrlString = urlString 655 | else 656 | absoluteUrlString = url.resolve @nodejsBaseUrl, urlString 657 | 658 | xhrUrl = url.parse absoluteUrlString, false, true 659 | xhrUrl.hash = null 660 | if xhrUrl.auth and (user? or password?) 661 | index = xhrUrl.auth.indexOf ':' 662 | if index is -1 663 | user = xhrUrl.auth unless user 664 | else 665 | user = xhrUrl.substring(0, index) unless user 666 | password = xhrUrl.substring(index + 1) unless password 667 | if user or password 668 | xhrUrl.auth = "#{user}:#{password}" 669 | xhrUrl 670 | 671 | # Reads the headers from a node.js ClientResponse instance. 672 | # 673 | # @private 674 | # @param {http.ClientResponse} response the response whose headers will be 675 | # imported into this XMLHttpRequest's state 676 | # @return {undefined} undefined 677 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-getresponseheader()-method 678 | # @see http://www.w3.org/TR/XMLHttpRequest/#the-getallresponseheaders()-method 679 | _parseResponseHeaders: (response) -> 680 | @_responseHeaders = {} 681 | for name, value of response.headers 682 | loweredName = name.toLowerCase() 683 | continue if @_privateHeaders[loweredName] 684 | if @_mimeOverride isnt null and loweredName is 'content-type' 685 | value = @_mimeOverride 686 | @_responseHeaders[loweredName] = value 687 | 688 | if @_mimeOverride isnt null and !('content-type' of @_responseHeaders) 689 | @_responseHeaders['content-type'] = @_mimeOverride 690 | undefined 691 | 692 | # Sets the response and responseText properties when an XHR completes. 693 | # 694 | # @private 695 | # @return {undefined} undefined 696 | _parseResponse: -> 697 | if Buffer.concat 698 | buffer = Buffer.concat @_responseParts 699 | else 700 | # node 0.6 701 | buffer = @_concatBuffers @_responseParts 702 | @_responseParts = null 703 | 704 | switch @responseType 705 | when 'text' 706 | @_parseTextResponse buffer 707 | when 'json' 708 | @responseText = null 709 | try 710 | @response = JSON.parse buffer.toString('utf-8') 711 | catch jsonError 712 | @response = null 713 | when 'buffer' 714 | @responseText = null 715 | @response = buffer 716 | when 'arraybuffer' 717 | @responseText = null 718 | arrayBuffer = new ArrayBuffer buffer.length 719 | view = new Uint8Array arrayBuffer 720 | view[i] = buffer[i] for i in [0...buffer.length] 721 | @response = arrayBuffer 722 | else 723 | # TODO(pwnall): content-base detection 724 | @_parseTextResponse buffer 725 | undefined 726 | 727 | # Sets response and responseText for a 'text' response type. 728 | # 729 | # @private 730 | # @param {Buffer} buffer the node.js Buffer containing the binary response 731 | # @return {undefined} undefined 732 | _parseTextResponse: (buffer) -> 733 | try 734 | @responseText = buffer.toString @_parseResponseEncoding() 735 | catch e 736 | # Unknown encoding. 737 | @responseText = buffer.toString 'binary' 738 | 739 | @response = @responseText 740 | undefined 741 | 742 | # Figures out the string encoding of the XHR's response. 743 | # 744 | # This is called to determine the encoding when responseText is set. 745 | # 746 | # @private 747 | # @return {String} a string encoding, e.g. 'utf-8' 748 | _parseResponseEncoding: -> 749 | encoding = null 750 | if contentType = @_responseHeaders['content-type'] 751 | if match = /\;\s*charset\=(.*)$/.exec contentType 752 | return match[1] 753 | 'utf-8' 754 | 755 | # Buffer.concat implementation for node 0.6. 756 | # 757 | # @private 758 | # @param {Array} buffers the buffers whose contents will be merged 759 | # @return {Buffer} same as Buffer.concat(buffers) in node 0.8 and above 760 | _concatBuffers: (buffers) -> 761 | if buffers.length is 0 762 | return Buffer.alloc 0 763 | if buffers.length is 1 764 | return buffers[0] 765 | 766 | length = 0 767 | length += buffer.length for buffer in buffers 768 | target = Buffer.alloc length 769 | length = 0 770 | for buffer in buffers 771 | buffer.copy target, length 772 | length += buffer.length 773 | target 774 | 775 | # XMLHttpRequest is the result of require('node-xhr2'). 776 | module.exports = XMLHttpRequest 777 | 778 | # Make node-xhr2 work as a drop-in replacement for libraries that promote the 779 | # following usage pattern: 780 | # var XMLHttpRequest = require('xhr-library-name').XMLHttpRequest 781 | XMLHttpRequest.XMLHttpRequest = XMLHttpRequest 782 | --------------------------------------------------------------------------------