1/** 2 * Wrapper for built-in http.js to emulate the browser XMLHttpRequest object. 3 * 4 * This can be used with JS designed for browsers to improve reuse of code and 5 * allow the use of existing libraries. 6 * 7 * Usage: include("XMLHttpRequest.js") and use XMLHttpRequest per W3C specs. 8 * 9 * @author Dan DeFelippi <dan@driverdan.com> 10 * @contributor David Ellis <d.f.ellis@ieee.org> 11 * @contributor Guillaume Grossetie <ggrossetie@yuzutech.fr> 12 * @contributor David Jencks <djencks@apache.org> 13 * @license MIT 14 */ 15const Url = require('url') 16const fs = require('fs') 17const ospath = require('path') 18 19// 100 MB 20const DEFAULT_MAX_BUFFER = 1000 * 1000 * 100 21 22exports.XMLHttpRequest = function () { 23 'use strict' 24 25 /** 26 * Private variables 27 */ 28 const self = this 29 const http = require('http') 30 const https = require('https') 31 32 // Holds http.js objects 33 let request 34 let response 35 36 // Request settings 37 let settings = {} 38 39 // Disable header blacklist. 40 // Not part of XHR specs. 41 let disableHeaderCheck = false 42 43 // Set some default headers 44 const defaultHeaders = { 45 'User-Agent': 'node-XMLHttpRequest', 46 Accept: '*/*' 47 } 48 49 let headers = {} 50 const headersCase = {} 51 52 // These headers are not user setable. 53 // The following are allowed but banned in the spec: 54 // * user-agent 55 const forbiddenRequestHeaders = [ 56 'accept-charset', 57 'accept-encoding', 58 'access-control-request-headers', 59 'access-control-request-method', 60 'connection', 61 'content-length', 62 'content-transfer-encoding', 63 'cookie', 64 'cookie2', 65 'date', 66 'expect', 67 'host', 68 'keep-alive', 69 'origin', 70 'referer', 71 'te', 72 'trailer', 73 'transfer-encoding', 74 'upgrade', 75 'via' 76 ] 77 78 // These request methods are not allowed 79 const forbiddenRequestMethods = [ 80 'TRACE', 81 'TRACK', 82 'CONNECT' 83 ] 84 85 // Send flag 86 let sendFlag = false 87 // Error flag, used when errors occur or abort is called 88 let errorFlag = false 89 90 // Binary response (chunk) 91 const responseBinary = [] 92 93 // Event listeners 94 const listeners = {} 95 96 /** 97 * Constants 98 */ 99 100 this.UNSENT = 0 101 this.OPENED = 1 102 this.HEADERS_RECEIVED = 2 103 this.LOADING = 3 104 this.DONE = 4 105 106 /** 107 * Public vars 108 */ 109 110 // Current state 111 this.readyState = this.UNSENT 112 113 // default ready state change handler in case one is not set or is set late 114 this.onreadystatechange = null 115 116 // Result & response 117 this.responseText = '' 118 this.responseXML = '' 119 this.status = null 120 this.statusText = null 121 122 // Whether cross-site Access-Control requests should be made using 123 // credentials such as cookies or authorization headers 124 this.withCredentials = false 125 // "text", "arraybuffer", "blob", or "document", depending on your data needs. 126 // Note, setting xhr.responseType = '' (or omitting) will default the response to "text". 127 // Omitting, '', or "text" will return a String. 128 // Other values will return an ArrayBuffer. 129 this.responseType = '' 130 131 /** 132 * Private methods 133 */ 134 135 /** 136 * Check if the specified header is allowed. 137 * 138 * @param header - {string} Header to validate 139 * @return {boolean} - False if not allowed, otherwise true 140 */ 141 const isAllowedHttpHeader = function (header) { 142 return disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1) 143 } 144 145 /** 146 * Check if the specified method is allowed. 147 * 148 * @param method - {string} Request method to validate 149 * @return {boolean} - False if not allowed, otherwise true 150 */ 151 const isAllowedHttpMethod = function (method) { 152 return (method && forbiddenRequestMethods.indexOf(method) === -1) 153 } 154 155 /** 156 * Public methods 157 */ 158 159 /** 160 * Open the connection. Currently supports local server requests. 161 * 162 * @param method - {string} Connection method (eg GET, POST) 163 * @param url - {string} URL for the connection. 164 * @param async - {boolean} Asynchronous connection. Default is true. 165 * @param [user] - {string} Username for basic authentication (optional) 166 * @param [password] - {string} Password for basic authentication (optional) 167 */ 168 this.open = function (method, url, async, user, password) { 169 this.abort() 170 errorFlag = false 171 172 // Check for valid request method 173 if (!isAllowedHttpMethod(method)) { 174 throw new Error('SecurityError: Request method not allowed') 175 } 176 177 settings = { 178 method: method, 179 url: url.toString(), 180 async: (typeof async !== 'boolean' ? true : async), 181 user: user || null, 182 password: password || null 183 } 184 185 setState(this.OPENED) 186 } 187 188 /** 189 * Disables or enables isAllowedHttpHeader() check the request. Enabled by default. 190 * This does not conform to the W3C spec. 191 * 192 * @param state - {boolean} Enable or disable header checking. 193 */ 194 this.setDisableHeaderCheck = function (state) { 195 disableHeaderCheck = state 196 } 197 198 /** 199 * Sets a header for the request or appends the value if one is already set. 200 * 201 * @param header - {string} Header name 202 * @param value - {string} Header value 203 */ 204 this.setRequestHeader = function (header, value) { 205 if (this.readyState !== this.OPENED) { 206 throw new Error('INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN') 207 } 208 if (!isAllowedHttpHeader(header)) { 209 console.warn('Refused to set unsafe header "' + header + '"') 210 return 211 } 212 if (sendFlag) { 213 throw new Error('INVALID_STATE_ERR: send flag is true') 214 } 215 header = headersCase[header.toLowerCase()] || header 216 headersCase[header.toLowerCase()] = header 217 headers[header] = headers[header] ? headers[header] + ', ' + value : value 218 } 219 220 /** 221 * Gets a header from the server response. 222 * 223 * @param header - {string} Name of header to get. 224 * @return {Object} - Text of the header or null if it doesn't exist. 225 */ 226 this.getResponseHeader = function (header) { 227 if (typeof header === 'string' && 228 this.readyState > this.OPENED && 229 response && 230 response.headers && 231 response.headers[header.toLowerCase()] && 232 !errorFlag 233 ) { 234 return response.headers[header.toLowerCase()] 235 } 236 237 return null 238 } 239 240 /** 241 * Gets all the response headers. 242 * 243 * @return string A string with all response headers separated by CR+LF 244 */ 245 this.getAllResponseHeaders = function () { 246 if (this.readyState < this.HEADERS_RECEIVED || errorFlag) { 247 return '' 248 } 249 let result = '' 250 251 for (const i in response.headers) { 252 // Cookie headers are excluded 253 if (i !== 'set-cookie' && i !== 'set-cookie2') { 254 result += i + ': ' + response.headers[i] + '\r\n' 255 } 256 } 257 return result.substr(0, result.length - 2) 258 } 259 260 /** 261 * Gets a request header 262 * 263 * @param name - {string} Name of header to get 264 * @return {string} Returns the request header or empty string if not set 265 */ 266 this.getRequestHeader = function (name) { 267 if (typeof name === 'string' && headersCase[name.toLowerCase()]) { 268 return headers[headersCase[name.toLowerCase()]] 269 } 270 271 return '' 272 } 273 274 /** 275 * Sends the request to the server. 276 * 277 * @param data - {string} Optional data to send as request body. 278 */ 279 this.send = function (data) { 280 if (this.readyState !== this.OPENED) { 281 throw new Error('INVALID_STATE_ERR: connection must be opened before send() is called') 282 } 283 284 if (sendFlag) { 285 throw new Error('INVALID_STATE_ERR: send has already been called') 286 } 287 let ssl = false 288 let local = false 289 const url = new Url.URL(settings.url) 290 let host 291 // Determine the server 292 switch (url.protocol) { 293 case 'https:': 294 ssl = true 295 host = url.hostname 296 break 297 case 'http:': 298 host = url.hostname 299 break 300 case 'file:': 301 local = true 302 break 303 case undefined: 304 case null: 305 case '': 306 host = 'localhost' 307 break 308 default: 309 throw new Error('Protocol not supported.') 310 } 311 312 // Load files off the local filesystem (file://) 313 if (local) { 314 if (settings.method !== 'GET') { 315 throw new Error('XMLHttpRequest: Only GET method is supported') 316 } 317 if (settings.async) { 318 fs.readFile(url, 'utf8', function (error, data) { 319 if (error) { 320 self.handleError(error, url) 321 } else { 322 self.status = 200 323 self.responseText = data 324 setState(self.DONE) 325 } 326 }) 327 } else { 328 try { 329 this.responseText = fs.readFileSync(url, 'utf8') 330 this.status = 200 331 setState(self.DONE) 332 } catch (e) { 333 this.handleError(e, url) 334 } 335 } 336 337 return 338 } 339 340 // Default to port 80. If accessing localhost on another port be sure 341 // to use http://localhost:port/path 342 const port = url.port || (ssl ? 443 : 80) 343 // Add query string if one is used 344 const uri = url.pathname + (url.search ? url.search : '') 345 346 // Set the defaults if they haven't been set 347 for (const name in defaultHeaders) { 348 if (!headersCase[name.toLowerCase()]) { 349 headers[name] = defaultHeaders[name] 350 } 351 } 352 353 // Set the Host header or the server may reject the request 354 headers.Host = host 355 // IPv6 addresses must be escaped with brackets 356 if (url.host[0] === '[') { 357 headers.Host = '[' + headers.Host + ']' 358 } 359 if (!((ssl && port === 443) || port === 80)) { 360 headers.Host += ':' + url.port 361 } 362 363 // Set Basic Auth if necessary 364 if (settings.user) { 365 if (typeof settings.password === 'undefined') { 366 settings.password = '' 367 } 368 const authBuf = Buffer.from(settings.user + ':' + settings.password) 369 headers.Authorization = 'Basic ' + authBuf.toString('base64') 370 } 371 372 // Set content length header 373 if (settings.method === 'GET' || settings.method === 'HEAD') { 374 data = null 375 } else if (data) { 376 headers['Content-Length'] = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data) 377 378 if (!this.getRequestHeader('Content-Type')) { 379 headers['Content-Type'] = 'text/plain;charset=UTF-8' 380 } 381 } else if (settings.method === 'POST') { 382 // For a post with no data set Content-Length: 0. 383 // This is required by buggy servers that don't meet the specs. 384 headers['Content-Length'] = 0 385 } 386 387 const options = { 388 host: host, 389 port: port, 390 path: uri, 391 method: settings.method, 392 headers: headers, 393 agent: false, 394 withCredentials: self.withCredentials 395 } 396 397 const responseType = this.responseType || 'text' 398 399 // Reset error flag 400 errorFlag = false 401 402 // Handle async requests 403 if (settings.async) { 404 // Use the proper protocol 405 const doRequest = ssl ? https.request : http.request 406 407 // Request is being sent, set send flag 408 sendFlag = true 409 410 // As per spec, this is called here for historical reasons. 411 self.dispatchEvent('readystatechange') 412 413 // Handler for the response 414 const responseHandler = function responseHandler (resp) { 415 // Set response var to the response we got back 416 // This is so it remains accessable outside this scope 417 response = resp 418 // Check for redirect 419 // @TODO Prevent looped redirects 420 if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307) { 421 // Change URL to the redirect location 422 settings.url = response.headers.location 423 const url = new Url.URL(settings.url) 424 // Set host var in case it's used later 425 host = url.hostname 426 // Options for the new request 427 const newOptions = { 428 hostname: url.hostname, 429 port: url.port, 430 path: url.path, 431 method: response.statusCode === 303 ? 'GET' : settings.method, 432 headers: headers, 433 withCredentials: self.withCredentials 434 } 435 436 // Issue the new request 437 request = doRequest(newOptions, responseHandler).on('error', errorHandler) 438 request.end() 439 // @TODO Check if an XHR event needs to be fired here 440 return 441 } 442 443 const encoding = responseType === 'text' ? 'utf8' : 'binary' 444 if (encoding === 'utf8') { 445 response.setEncoding('utf8') 446 } 447 448 setState(self.HEADERS_RECEIVED) 449 self.status = response.statusCode 450 451 if (encoding === 'utf8') { 452 response.on('data', function (chunk) { 453 // Make sure there's some data 454 if (chunk) { 455 self.responseText += chunk 456 } 457 // Don't emit state changes if the connection has been aborted. 458 if (sendFlag) { 459 setState(self.LOADING) 460 } 461 }) 462 463 response.on('end', function () { 464 if (sendFlag) { 465 // Discard the end event if the connection has been aborted 466 setState(self.DONE) 467 sendFlag = false 468 } 469 }) 470 } else { 471 response.on('data', function (chunk) { 472 // Make sure there's some data 473 if (chunk) { 474 responseBinary.push(chunk) 475 } 476 // Don't emit state changes if the connection has been aborted. 477 if (sendFlag) { 478 setState(self.LOADING) 479 } 480 }) 481 482 response.on('end', function () { 483 // buffers are Uint8Array instances 484 self.response = Buffer.concat(responseBinary).buffer 485 if (sendFlag) { 486 // Discard the end event if the connection has been aborted 487 setState(self.DONE) 488 sendFlag = false 489 } 490 }) 491 } 492 493 response.on('error', function (error) { 494 self.handleError(error, url) 495 }) 496 } 497 498 // Error handler for the request 499 const errorHandler = function errorHandler (error) { 500 self.handleError(error, url) 501 } 502 503 // Create the request 504 request = doRequest(options, responseHandler).on('error', errorHandler) 505 506 // Node 0.4 and later won't accept empty data. Make sure it's needed. 507 if (data) { 508 request.write(data) 509 } 510 511 request.end() 512 513 self.dispatchEvent('loadstart') 514 } else { // Synchronous 515 const maxBuffer = process.env.UNXHR_MAX_BUFFER 516 ? parseInt(process.env.UNXHR_MAX_BUFFER) 517 : DEFAULT_MAX_BUFFER 518 const encoding = responseType === 'text' ? 'utf8' : 'binary' 519 const scriptPath = ospath.join(__dirname, 'request.js') 520 const output = require('child_process').execSync(`"${process.execPath}" "${scriptPath}" \ 521--ssl="${ssl}" \ 522--encoding="${encoding}" \ 523--request-options=${JSON.stringify(JSON.stringify(options))}`, { stdio: ['pipe', 'pipe', 'pipe'], input: data, maxBuffer: maxBuffer }) 524 const result = JSON.parse(output.toString('utf8')) 525 if (result.error) { 526 throw translateError(result.error, url) 527 } else { 528 response = result.data 529 self.status = result.data.statusCode 530 if (encoding === 'binary') { 531 self.response = Uint8Array.from(result.data.binary.data).buffer 532 } else { 533 self.responseText = result.data.text 534 } 535 setState(self.DONE) 536 } 537 } 538 } 539 540 /** 541 * Called when an error is encountered to deal with it. 542 */ 543 this.handleError = function (error, url) { 544 this.status = 0 545 this.statusText = '' 546 this.responseText = '' 547 errorFlag = true 548 setState(this.DONE) 549 this.dispatchEvent('error', { error: translateError(error, url) }) 550 } 551 552 /** 553 * Aborts a request. 554 */ 555 this.abort = function () { 556 if (request) { 557 request.abort() 558 request = null 559 } 560 561 headers = defaultHeaders 562 this.status = 0 563 this.responseText = '' 564 this.responseXML = '' 565 566 errorFlag = true 567 568 if (this.readyState !== this.UNSENT && 569 (this.readyState !== this.OPENED || sendFlag) && 570 this.readyState !== this.DONE) { 571 sendFlag = false 572 setState(this.DONE) 573 } 574 this.readyState = this.UNSENT 575 this.dispatchEvent('abort') 576 } 577 578 /** 579 * Adds an event listener. Preferred method of binding to events. 580 */ 581 this.addEventListener = function (event, callback) { 582 if (!(event in listeners)) { 583 listeners[event] = [] 584 } 585 // Currently allows duplicate callbacks. Should it? 586 listeners[event].push(callback) 587 } 588 589 /** 590 * Remove an event callback that has already been bound. 591 * Only works on the matching funciton, cannot be a copy. 592 */ 593 this.removeEventListener = function (event, callback) { 594 if (event in listeners) { 595 // Filter will return a new array with the callback removed 596 listeners[event] = listeners[event].filter(function (ev) { 597 return ev !== callback 598 }) 599 } 600 } 601 602 /** 603 * Dispatch any events, including both "on" methods and events attached using addEventListener. 604 */ 605 this.dispatchEvent = function (event, args) { 606 if (typeof self['on' + event] === 'function') { 607 self['on' + event](args) 608 } 609 if (event in listeners) { 610 for (let i = 0, len = listeners[event].length; i < len; i++) { 611 listeners[event][i].call(self, args) 612 } 613 } 614 } 615 616 /** 617 * Changes readyState and calls onreadystatechange. 618 * 619 * @param state - {Number} New state 620 */ 621 const setState = function (state) { 622 if (state === self.LOADING || self.readyState !== state) { 623 self.readyState = state 624 625 if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) { 626 self.dispatchEvent('readystatechange') 627 } 628 629 if (self.readyState === self.DONE && !errorFlag) { 630 self.dispatchEvent('load') 631 // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) 632 self.dispatchEvent('loadend') 633 } 634 } 635 } 636 637 const translateError = function (error, url) { 638 if (typeof error === 'object') { 639 if (error.code === 'ENOTFOUND' || error.code === 'EAI_AGAIN') { 640 // XMLHttpRequest throws a DOMException when DNS lookup fails: 641 // code: 19 642 // message: "Failed to execute 'send' on 'XMLHttpRequest': Failed to load 'http://url/'." 643 // name: "NetworkError" 644 // stack: (...) 645 return new Error(`Failed to execute 'send' on 'XMLHttpRequest': Failed to load '${url}'.`) 646 } 647 if (error instanceof Error) { 648 return error 649 } 650 return new Error(JSON.stringify(error)) 651 } 652 return new Error(error) 653 } 654} 655