xhr2
Version:
XMLHttpRequest emulation for node.js
782 lines (686 loc) • 26.3 kB
text/coffeescript
# This file's name is set up in such a way that it will always show up second
# in the list of files given to coffee --join, so it can use the
# XMLHttpRequestEventTarget definition and so that the other files can assume
# that XMLHttpRequest was already defined.
http = require 'http'
https = require 'https'
os = require 'os'
url = require 'url'
# The ECMAScript HTTP API.
#
# @see http://www.w3.org/TR/XMLHttpRequest/#introduction
class XMLHttpRequest extends XMLHttpRequestEventTarget
# Creates a new request.
#
# @param {Object} options one or more of the options below
# @option options {Boolean} anon if true, the request's anonymous flag
# will be set
# @see http://www.w3.org/TR/XMLHttpRequest/#constructors
# @see http://www.w3.org/TR/XMLHttpRequest/#anonymous-flag
constructor: (options) ->
super()
@onreadystatechange = null
@_anonymous = options and options.anon
@readyState = XMLHttpRequest.UNSENT
@response = null
@responseText = ''
@responseType = ''
@responseURL = ''
@status = 0
@statusText = ''
@timeout = 0
@upload = new XMLHttpRequestUpload @
@_method = null # String
@_url = null # Return value of url.parse()
@_sync = false
@_headers = null # Object<String, String>
@_loweredHeaders = null # Object<lowercase String, String>
@_mimeOverride = null
@_request = null # http.ClientRequest
@_response = null # http.ClientResponse
@_responseParts = null # Array<Buffer, String>
@_responseHeaders = null # Object<lowercase String, String>
@_aborting = null
@_error = null
@_loadedBytes = 0
@_totalBytes = 0
@_lengthComputable = false
# @property {function(ProgressEvent)} DOM level 0-style handler for the
# 'readystatechange' event
onreadystatechange: null
# @property {Number} the current state of the XHR object
# @see http://www.w3.org/TR/XMLHttpRequest/#states
readyState: null
# @property {String, ArrayBuffer, Buffer, Object} processed XHR response
# @see http://www.w3.org/TR/XMLHttpRequest/#the-response-attribute
response: null
# @property {String} response string, if responseType is '' or 'text'
# @see http://www.w3.org/TR/XMLHttpRequest/#the-responsetext-attribute
responseText: null
# @property {String} sets the parsing method for the XHR response
# @see http://www.w3.org/TR/XMLHttpRequest/#the-responsetype-attribute
responseType: null
# @property {Number} the HTTP
# @see http://www.w3.org/TR/XMLHttpRequest/#the-status-attribute
status: null
# @property {Number} milliseconds to wait for the request to complete
# @see http://www.w3.org/TR/XMLHttpRequest/#the-timeout-attribute
timeout: null
# @property {XMLHttpRequestUpload} the associated upload information
# @see http://www.w3.org/TR/XMLHttpRequest/#the-upload-attribute
upload: null
# Sets the XHR's method, URL, synchronous flag, and authentication params.
#
# @param {String} method the HTTP method to be used
# @param {String} url the URL that the request will be made to
# @param {?Boolean} async if false, the XHR should be processed
# synchronously; true by default
# @param {?String} user the user credential to be used in HTTP basic
# authentication
# @param {?String} password the password credential to be used in HTTP basic
# authentication
# @return {undefined} undefined
# @throw {SecurityError} method is not one of the allowed methods
# @throw {SyntaxError} urlString is not a valid URL
# @throw {Error} the URL contains an unsupported protocol; the supported
# protocols are file, http and https
# @see http://www.w3.org/TR/XMLHttpRequest/#the-open()-method
open: (method, url, async, user, password) ->
method = method.toUpperCase()
if method of @_restrictedMethods
throw new SecurityError "HTTP method #{method} is not allowed in XHR"
xhrUrl = @_parseUrl url
async = true if async is undefined
switch @readyState
when XMLHttpRequest.UNSENT, XMLHttpRequest.OPENED, XMLHttpRequest.DONE
# Nothing to do here.
null
when XMLHttpRequest.HEADERS_RECEIVED, XMLHttpRequest.LOADING
# TODO(pwnall): terminate abort(), terminate send()
null
@_method = method
@_url = xhrUrl
@_sync = !async
@_headers = {}
@_loweredHeaders = {}
@_mimeOverride = null
@_setReadyState XMLHttpRequest.OPENED
@_request = null
@_response = null
@status = 0
@statusText = ''
@_responseParts = []
@_responseHeaders = null
@_loadedBytes = 0
@_totalBytes = 0
@_lengthComputable = false
undefined
# Appends a header to the list of author request headers.
#
# @param {String} name the HTTP header name
# @param {String} value the HTTP header value
# @return {undefined} undefined
# @throw {InvalidStateError} readyState is not OPENED
# @throw {SyntaxError} name is not a valid HTTP header name or value is not
# a valid HTTP header value
# @see http://www.w3.org/TR/XMLHttpRequest/#the-setrequestheader()-method
setRequestHeader: (name, value) ->
unless @readyState is XMLHttpRequest.OPENED
throw new InvalidStateError "XHR readyState must be OPENED"
loweredName = name.toLowerCase()
if @_restrictedHeaders[loweredName] or /^sec\-/.test(loweredName) or
/^proxy-/.test(loweredName)
console.warn "Refused to set unsafe header \"#{name}\""
return undefined
value = value.toString()
if loweredName of @_loweredHeaders
# Combine value with the existing header value.
name = @_loweredHeaders[loweredName]
@_headers[name] = @_headers[name] + ', ' + value
else
# New header.
@_loweredHeaders[loweredName] = name
@_headers[name] = value
undefined
# Initiates the request.
#
# @param {?String, ?ArrayBufferView} data the data to be sent; ignored for
# GET and HEAD requests
# @return {undefined} undefined
# @throw {InvalidStateError} readyState is not OPENED
# @see http://www.w3.org/TR/XMLHttpRequest/#the-send()-method
send: (data) ->
unless @readyState is XMLHttpRequest.OPENED
throw new InvalidStateError "XHR readyState must be OPENED"
if @_request
throw new InvalidStateError "send() already called"
switch @_url.protocol
when 'file:'
@_sendFile data
when 'http:', 'https:'
@_sendHttp data
else
throw new NetworkError "Unsupported protocol #{@_url.protocol}"
undefined
# Cancels the network activity performed by this request.
#
# @return {undefined} undefined
# @see http://www.w3.org/TR/XMLHttpRequest/#the-abort()-method
abort: ->
return unless @_request
@_request.abort()
@_setError()
@_dispatchProgress 'abort'
@_dispatchProgress 'loadend'
undefined
# Returns a header value in the HTTP response for this XHR.
#
# @param {String} name case-insensitive HTTP header name
# @return {?String} value the value of the header whose name matches the
# given name, or null if there is no such header
# @see http://www.w3.org/TR/XMLHttpRequest/#the-getresponseheader()-method
getResponseHeader: (name) ->
return null unless @_responseHeaders
loweredName = name.toLowerCase()
if loweredName of @_responseHeaders
@_responseHeaders[loweredName]
else
null
# Returns all the HTTP headers in this XHR's response.
#
# @return {String} header lines separated by CR LF, where each header line
# has the name and value separated by a ": " (colon, space); the empty
# string is returned if the headers are not available
# @see http://www.w3.org/TR/XMLHttpRequest/#the-getallresponseheaders()-method
getAllResponseHeaders: ->
return '' unless @_responseHeaders
lines = ("#{name}: #{value}" for name, value of @_responseHeaders)
lines.join "\r\n"
# Overrides the Content-Type
#
# @return {undefined} undefined
# @see http://www.w3.org/TR/XMLHttpRequest/#the-overridemimetype()-method
overrideMimeType: (newMimeType) ->
if @readyState is XMLHttpRequest.LOADING or
@readyState is XMLHttpRequest.DONE
throw new InvalidStateError(
"overrideMimeType() not allowed in LOADING or DONE")
@_mimeOverride = newMimeType.toLowerCase()
undefined
# Network configuration not exposed in the XHR API.
#
# Although the XMLHttpRequest specification calls itself "ECMAScript HTTP",
# it assumes that requests are always performed in the context of a browser
# application, where some network parameters are set by the browser user and
# should not be modified by Web applications. This API provides access to
# these network parameters.
#
# NOTE: this is not in the XMLHttpRequest API, and will not work in
# browsers. It is a stable node-xhr2 API.
#
# @param {Object} options one or more of the options below
# @option options {?http.Agent} httpAgent the value for the nodejsHttpAgent
# property (the agent used for HTTP requests)
# @option options {?https.Agent} httpsAgent the value for the
# nodejsHttpsAgent property (the agent used for HTTPS requests)
# @return {undefined} undefined
nodejsSet: (options) ->
if 'httpAgent' of options
@nodejsHttpAgent = options.httpAgent
if 'httpsAgent' of options
@nodejsHttpsAgent = options.httpsAgent
if 'baseUrl' of options
baseUrl = options.baseUrl
unless baseUrl is null
parsedUrl = url.parse baseUrl, false, true
unless parsedUrl.protocol
throw new SyntaxError("baseUrl must be an absolute URL")
@nodejsBaseUrl = baseUrl
undefined
# Default settings for the network configuration not exposed in the XHR API.
#
# NOTE: this is not in the XMLHttpRequest API, and will not work in
# browsers. It is a stable node-xhr2 API.
#
# @param {Object} options one or more of the options below
# @option options {?http.Agent} httpAgent the default value for the
# nodejsHttpAgent property (the agent used for HTTP requests)
# @option options {https.Agent} httpsAgent the default value for the
# nodejsHttpsAgent property (the agent used for HTTPS requests)
# @return {undefined} undefined
# @see XMLHttpRequest.nodejsSet
@nodejsSet: (options) ->
# "this" will be set to XMLHttpRequest.prototype, so the instance nodejsSet
# operates on default property values.
XMLHttpRequest::nodejsSet options
undefined
# readyState value before XMLHttpRequest#open() is called
UNSENT: 0
# readyState value before XMLHttpRequest#open() is called
@UNSENT: 0
# readyState value after XMLHttpRequest#open() is called, and before
# XMLHttpRequest#send() is called; XMLHttpRequest#setRequestHeader() can be
# called in this state
OPENED: 1
# readyState value after XMLHttpRequest#open() is called, and before
# XMLHttpRequest#send() is called; XMLHttpRequest#setRequestHeader() can be
# called in this state
@OPENED: 1
# readyState value after redirects have been followed and the HTTP headers of
# the final response have been received
HEADERS_RECEIVED: 2
# readyState value after redirects have been followed and the HTTP headers of
# the final response have been received
@HEADERS_RECEIVED: 2
# readyState value when the response entity body is being received
LOADING: 3
# readyState value when the response entity body is being received
@LOADING: 3
# readyState value after the request has been completely processed
DONE: 4
# readyState value after the request has been completely processed
@DONE: 4
# @property {http.Agent} the agent option passed to HTTP requests
#
# NOTE: this is not in the XMLHttpRequest API, and will not work in browsers.
# It is a stable node-xhr2 API that is useful for testing & going through
# web-proxies.
nodejsHttpAgent: http.globalAgent
# @property {https.Agent} the agent option passed to HTTPS requests
#
# NOTE: this is not in the XMLHttpRequest API, and will not work in browsers.
# It is a stable node-xhr2 API that is useful for testing & going through
# web-proxies.
nodejsHttpsAgent: https.globalAgent
# @property {String} the base URL that relative URLs get resolved to
#
# NOTE: this is not in the XMLHttpRequest API, and will not work in browsers.
# Its browser equivalent is the base URL of the document associated with the
# Window object. It is a stable node-xhr2 API provided for libraries such as
# Angular Universal.
nodejsBaseUrl: null
# HTTP methods that are disallowed in the XHR spec.
#
# @private
# @see Step 6 in http://www.w3.org/TR/XMLHttpRequest/#the-open()-method
_restrictedMethods:
CONNECT: true
TRACE: true
TRACK: true
# HTTP request headers that are disallowed in the XHR spec.
#
# @private
# @see Step 5 in
# http://www.w3.org/TR/XMLHttpRequest/#the-setrequestheader()-method
_restrictedHeaders:
'accept-charset': true
'accept-encoding': true
'access-control-request-headers': true
'access-control-request-method': true
connection: true
'content-length': true
cookie: true
cookie2: true
date: true
dnt: true
expect: true
host: true
'keep-alive': true
origin: true
referer: true
te: true
trailer: true
'transfer-encoding': true
upgrade: true
via: true
# HTTP response headers that should not be exposed according to the XHR spec.
#
# @private
# @see Step 3 in
# http://www.w3.org/TR/XMLHttpRequest/#the-getresponseheader()-method
_privateHeaders:
'set-cookie': true
'set-cookie2': true
# The default value of the User-Agent header.
_userAgent: "Mozilla/5.0 (#{os.type()} #{os.arch()}) " +
"node.js/#{process.versions.node} v8/#{process.versions.v8}"
# Sets the readyState property and fires the readystatechange event.
#
# @private
# @param {Number} newReadyState the new value of readyState
# @return {undefined} undefined
_setReadyState: (newReadyState) ->
@readyState = newReadyState
event = new ProgressEvent 'readystatechange'
@dispatchEvent event
undefined
# XMLHttpRequest#send() implementation for the file: protocol.
#
# @private
_sendFile: ->
unless @_url.method is 'GET'
throw new NetworkError 'The file protocol only supports GET'
throw new Error "Protocol file: not implemented"
# XMLHttpRequest#send() implementation for the http: and https: protocols.
#
# @private
# This method sets the instance variables and calls _sendHxxpRequest(), which
# is responsible for building a node.js request and firing it off. The code
# in _sendHxxpRequest() is separated off so it can be reused when handling
# redirects.
#
# @see http://www.w3.org/TR/XMLHttpRequest/#infrastructure-for-the-send()-method
_sendHttp: (data) ->
if @_sync
throw new Error "Synchronous XHR processing not implemented"
if data? and (@_method is 'GET' or @_method is 'HEAD')
console.warn "Discarding entity body for #{@_method} requests"
data = null
else
# Send Content-Length: 0
data or= ''
# NOTE: this is called before finalizeHeaders so that the uploader can
# figure out Content-Length and Content-Type.
@upload._setData data
@_finalizeHeaders()
@_sendHxxpRequest()
undefined
# Sets up and fires off a HTTP/HTTPS request using the node.js API.
#
# @private
# This method contains the bulk of the XMLHttpRequest#send() implementation,
# and is also used to issue new HTTP requests when handling HTTP redirects.
#
# @see http://www.w3.org/TR/XMLHttpRequest/#infrastructure-for-the-send()-method
_sendHxxpRequest: ->
if @_url.protocol is 'http:'
hxxp = http
agent = @nodejsHttpAgent
else
hxxp = https
agent = @nodejsHttpsAgent
request = hxxp.request
hostname: @_url.hostname, port: @_url.port, path: @_url.path,
auth: @_url.auth, method: @_method, headers: @_headers, agent: agent
@_request = request
if @timeout
request.setTimeout @timeout, => @_onHttpTimeout request
request.on 'response', (response) => @_onHttpResponse request, response
request.on 'error', (error) => @_onHttpRequestError request, error
@upload._startUpload request
if @_request is request # An http error might have already fired.
@_dispatchProgress 'loadstart'
undefined
# Fills in the restricted HTTP headers with default values.
#
# This is called right before the HTTP request is sent off.
#
# @private
# @return {undefined} undefined
_finalizeHeaders: ->
@_headers['Connection'] = 'keep-alive'
@_headers['Host'] = @_url.host
if @_anonymous
@_headers['Referer'] = 'about:blank'
@_headers['User-Agent'] ||= @_userAgent
@upload._finalizeHeaders @_headers, @_loweredHeaders
undefined
# Called when the headers of an HTTP response have been received.
#
# @private
# @param {http.ClientRequest} request the node.js ClientRequest instance that
# produced this response
# @param {http.ClientResponse} response the node.js ClientResponse instance
# passed to
_onHttpResponse: (request, response) ->
return unless @_request is request
# Transparent redirection handling.
switch response.statusCode
when 301, 302, 303, 307, 308
@_url = @_parseUrl response.headers['location']
@_method = 'GET'
if 'content-type' of @_loweredHeaders
delete @_headers[@_loweredHeaders['content-type']]
delete @_loweredHeaders['content-type']
# XMLHttpRequestUpload#_finalizeHeaders() sets Content-Type directly.
if 'Content-Type' of @_headers
delete @_headers['Content-Type']
# Restricted headers can't be set by the user, no need to check
# loweredHeaders.
delete @_headers['Content-Length']
@upload._reset()
@_finalizeHeaders()
@_sendHxxpRequest()
return
@_response = response
@_response.on 'data', (data) => @_onHttpResponseData response, data
@_response.on 'end', => @_onHttpResponseEnd response
@_response.on 'close', => @_onHttpResponseClose response
@responseURL = @_url.href.split('#')[0]
@status = @_response.statusCode
@statusText = http.STATUS_CODES[@status]
@_parseResponseHeaders response
if lengthString = @_responseHeaders['content-length']
@_totalBytes = parseInt(lengthString)
@_lengthComputable = true
else
@_lengthComputable = false
@_setReadyState XMLHttpRequest.HEADERS_RECEIVED
# Called when some data has been received on a HTTP connection.
#
# @private
# @param {http.ClientResponse} response the node.js ClientResponse instance
# that fired this event
# @param {String, Buffer} data the data that has been received
_onHttpResponseData: (response, data) ->
return unless @_response is response
@_responseParts.push data
@_loadedBytes += data.length
if @readyState isnt XMLHttpRequest.LOADING
@_setReadyState XMLHttpRequest.LOADING
@_dispatchProgress 'progress'
# Called when the HTTP request finished processing.
#
# @private
# @param {http.ClientResponse} response the node.js ClientResponse instance
# that fired this event
_onHttpResponseEnd: (response) ->
return unless @_response is response
@_parseResponse()
@_request = null
@_response = null
@_setReadyState XMLHttpRequest.DONE
@_dispatchProgress 'load'
@_dispatchProgress 'loadend'
# Called when the underlying HTTP connection was closed prematurely.
#
# If this method is called, it will be called after or instead of
# onHttpResponseEnd.
#
# @private
# @param {http.ClientResponse} response the node.js ClientResponse instance
# that fired this event
_onHttpResponseClose: (response) ->
return unless @_response is response
request = @_request
@_setError()
request.abort()
@_setReadyState XMLHttpRequest.DONE
@_dispatchProgress 'error'
@_dispatchProgress 'loadend'
# Called when the timeout set on the HTTP socket expires.
#
# @private
# @param {http.ClientRequest} request the node.js ClientRequest instance that
# fired this event
_onHttpTimeout: (request) ->
return unless @_request is request
@_setError()
request.abort()
@_setReadyState XMLHttpRequest.DONE
@_dispatchProgress 'timeout'
@_dispatchProgress 'loadend'
# Called when something wrong happens on the HTTP socket
#
# @private
# @param {http.ClientRequest} request the node.js ClientRequest instance that
# fired this event
# @param {Error} error emitted exception
_onHttpRequestError: (request, error) ->
return unless @_request is request
@_setError()
request.abort()
@_setReadyState XMLHttpRequest.DONE
@_dispatchProgress 'error'
@_dispatchProgress 'loadend'
# Fires an XHR progress event.
#
# @private
# @param {String} eventType one of the XHR progress event types, such as
# 'load' and 'progress'
_dispatchProgress: (eventType) ->
event = new ProgressEvent eventType
event.lengthComputable = @_lengthComputable
event.loaded = @_loadedBytes
event.total = @_totalBytes
@dispatchEvent event
undefined
# Sets up the XHR to reflect the fact that an error has occurred.
#
# The possible errors are a network error, a timeout, or an abort.
#
# @private
_setError: ->
@_request = null
@_response = null
@_responseHeaders = null
@_responseParts = null
undefined
# Parses a request URL string.
#
# @private
# This method is a thin wrapper around url.parse() that normalizes HTTP
# user/password credentials. It is used to parse the URL string passed to
# XMLHttpRequest#open() and the URLs in the Location headers of HTTP redirect
# responses.
#
# @param {String} urlString the URL to be parsed
# @return {Object} parsed URL
_parseUrl: (urlString) ->
if @nodejsBaseUrl is null
absoluteUrlString = urlString
else
absoluteUrlString = url.resolve @nodejsBaseUrl, urlString
xhrUrl = url.parse absoluteUrlString, false, true
xhrUrl.hash = null
if xhrUrl.auth and (user? or password?)
index = xhrUrl.auth.indexOf ':'
if index is -1
user = xhrUrl.auth unless user
else
user = xhrUrl.substring(0, index) unless user
password = xhrUrl.substring(index + 1) unless password
if user or password
xhrUrl.auth = "#{user}:#{password}"
xhrUrl
# Reads the headers from a node.js ClientResponse instance.
#
# @private
# @param {http.ClientResponse} response the response whose headers will be
# imported into this XMLHttpRequest's state
# @return {undefined} undefined
# @see http://www.w3.org/TR/XMLHttpRequest/#the-getresponseheader()-method
# @see http://www.w3.org/TR/XMLHttpRequest/#the-getallresponseheaders()-method
_parseResponseHeaders: (response) ->
@_responseHeaders = {}
for name, value of response.headers
loweredName = name.toLowerCase()
continue if @_privateHeaders[loweredName]
if @_mimeOverride isnt null and loweredName is 'content-type'
value = @_mimeOverride
@_responseHeaders[loweredName] = value
if @_mimeOverride isnt null and !('content-type' of @_responseHeaders)
@_responseHeaders['content-type'] = @_mimeOverride
undefined
# Sets the response and responseText properties when an XHR completes.
#
# @private
# @return {undefined} undefined
_parseResponse: ->
if Buffer.concat
buffer = Buffer.concat @_responseParts
else
# node 0.6
buffer = @_concatBuffers @_responseParts
@_responseParts = null
switch @responseType
when 'text'
@_parseTextResponse buffer
when 'json'
@responseText = null
try
@response = JSON.parse buffer.toString('utf-8')
catch jsonError
@response = null
when 'buffer'
@responseText = null
@response = buffer
when 'arraybuffer'
@responseText = null
arrayBuffer = new ArrayBuffer buffer.length
view = new Uint8Array arrayBuffer
view[i] = buffer[i] for i in [0...buffer.length]
@response = arrayBuffer
else
# TODO(pwnall): content-base detection
@_parseTextResponse buffer
undefined
# Sets response and responseText for a 'text' response type.
#
# @private
# @param {Buffer} buffer the node.js Buffer containing the binary response
# @return {undefined} undefined
_parseTextResponse: (buffer) ->
try
@responseText = buffer.toString @_parseResponseEncoding()
catch e
# Unknown encoding.
@responseText = buffer.toString 'binary'
@response = @responseText
undefined
# Figures out the string encoding of the XHR's response.
#
# This is called to determine the encoding when responseText is set.
#
# @private
# @return {String} a string encoding, e.g. 'utf-8'
_parseResponseEncoding: ->
encoding = null
if contentType = @_responseHeaders['content-type']
if match = /\;\s*charset\=(.*)$/.exec contentType
return match[1]
'utf-8'
# Buffer.concat implementation for node 0.6.
#
# @private
# @param {Array<Buffer>} buffers the buffers whose contents will be merged
# @return {Buffer} same as Buffer.concat(buffers) in node 0.8 and above
_concatBuffers: (buffers) ->
if buffers.length is 0
return Buffer.alloc 0
if buffers.length is 1
return buffers[0]
length = 0
length += buffer.length for buffer in buffers
target = Buffer.alloc length
length = 0
for buffer in buffers
buffer.copy target, length
length += buffer.length
target
# XMLHttpRequest is the result of require('node-xhr2').
module.exports = XMLHttpRequest
# Make node-xhr2 work as a drop-in replacement for libraries that promote the
# following usage pattern:
# var XMLHttpRequest = require('xhr-library-name').XMLHttpRequest
XMLHttpRequest.XMLHttpRequest = XMLHttpRequest