/** * Wrapper for built-in http.js to emulate the browser XMLHttpRequest object. * * This can be used with JS designed for browsers to improve reuse of code and * allow the use of existing libraries. * * Usage: include("XMLHttpRequest.js") and use XMLHttpRequest per W3C specs. * * @author Dan DeFelippi * @contributor David Ellis * @license MIT */ const { URL } = require('node:url') const spawn = require('node:child_process').spawn const fs = require('node:fs') exports.XMLHttpRequest = function () { /** * Private variables */ const self = this const http = require('node:http') const https = require('node:https') // Holds http.js objects let request let response // Request settings let settings = {} // Set some default headers const defaultHeaders = { 'User-Agent': 'node-XMLHttpRequest', Accept: '*/*', } let headers = defaultHeaders // These headers are not user setable. // The following are allowed but banned in the spec: // * user-agent const forbiddenRequestHeaders = [ 'accept-charset', 'accept-encoding', 'access-control-request-headers', 'access-control-request-method', 'connection', 'content-length', 'content-transfer-encoding', // "cookie", 'cookie2', 'date', 'expect', 'host', 'keep-alive', 'origin', 'referer', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'via', ] // These request methods are not allowed const forbiddenRequestMethods = ['TRACE', 'TRACK', 'CONNECT'] // Send flag let sendFlag = false // Error flag, used when errors occur or abort is called let errorFlag = false // Event listeners const listeners = {} /** * Constants */ this.UNSENT = 0 this.OPENED = 1 this.HEADERS_RECEIVED = 2 this.LOADING = 3 this.DONE = 4 /** * Public vars */ // Current state this.readyState = this.UNSENT // default ready state change handler in case one is not set or is set late this.onreadystatechange = null // Result & response this.responseText = '' this.responseXML = '' this.status = null this.statusText = null /** * Private methods */ /** * Check if the specified header is allowed. * * @param string header Header to validate * @return boolean False if not allowed, otherwise true */ const isAllowedHttpHeader = function (header) { return ( header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1 ) } /** * Check if the specified method is allowed. * * @param string method Request method to validate * @return boolean False if not allowed, otherwise true */ const isAllowedHttpMethod = function (method) { return method && forbiddenRequestMethods.indexOf(method) === -1 } /** * Public methods */ /** * Open the connection. Currently supports local server requests. * * @param string method Connection method (eg GET, POST) * @param string url URL for the connection. * @param boolean async Asynchronous connection. Default is true. * @param string user Username for basic authentication (optional) * @param string password Password for basic authentication (optional) */ this.open = function (method, url, async, user, password) { this.abort() errorFlag = false // Check for valid request method if (!isAllowedHttpMethod(method)) { throw new Error('SecurityError: Request method not allowed') } settings = { method, url: url.toString(), async: typeof async !== 'boolean' ? true : async, user: user || null, password: password || null, } setState(this.OPENED) } /** * Sets a header for the request. * * @param string header Header name * @param string value Header value */ this.setRequestHeader = function (header, value) { if (this.readyState !== this.OPENED) { throw new Error( 'INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN' ) } if (!isAllowedHttpHeader(header)) { console.warn('Refused to set unsafe header "' + header + '"') return } if (sendFlag) { throw new Error('INVALID_STATE_ERR: send flag is true') } headers[header] = value } /** * Gets a header from the server response. * * @param string header Name of header to get. * @return string Text of the header or null if it doesn't exist. */ this.getResponseHeader = function (header) { if ( typeof header === 'string' && this.readyState > this.OPENED && response.headers[header.toLowerCase()] && !errorFlag ) { return response.headers[header.toLowerCase()] } return null } /** * Gets all the response headers. * * @return string A string with all response headers separated by CR+LF */ this.getAllResponseHeaders = function () { if (this.readyState < this.HEADERS_RECEIVED || errorFlag) { return '' } let result = '' for (const i in response.headers) { // Cookie headers are excluded if (i !== 'set-cookie' && i !== 'set-cookie2') { result += i + ': ' + response.headers[i] + '\r\n' } } return result.substr(0, result.length - 2) } /** * Gets a request header * * @param string name Name of header to get * @return string Returns the request header or empty string if not set */ this.getRequestHeader = function (name) { // @TODO Make this case insensitive if (typeof name === 'string' && headers[name]) { return headers[name] } return '' } /** * Sends the request to the server. * * @param string data Optional data to send as request body. */ this.send = function (data) { if (this.readyState !== this.OPENED) { throw new Error( 'INVALID_STATE_ERR: connection must be opened before send() is called' ) } if (sendFlag) { throw new Error('INVALID_STATE_ERR: send has already been called') } let host let ssl = false let local = false const url = new URL(settings.url) // Determine the server switch (url.protocol) { case 'https:': ssl = true host = url.hostname break case 'http:': host = url.hostname break case 'file:': local = true break case undefined: case '': host = '127.0.0.1' break default: throw new Error('Protocol not supported.') } // Load files off the local filesystem (file://) if (local) { if (settings.method !== 'GET') { throw new Error('XMLHttpRequest: Only GET method is supported') } if (settings.async) { fs.readFile(url.pathname, 'utf8', (error, data) => { if (error) { self.handleError(error) } else { self.status = 200 self.responseText = data setState(self.DONE) } }) } else { try { this.responseText = fs.readFileSync(url.pathname, 'utf8') this.status = 200 setState(self.DONE) } catch (e) { this.handleError(e) } } return } // Default to port 80. If accessing 127.0.0.1 on another port be sure // to use http://127.0.0.1:port/path const port = url.port || (ssl ? 443 : 80) // Add query string if one is used const uri = url.pathname + (url.search ? url.search : '') // Set the Host header or the server may reject the request headers.Host = host if (!((ssl && port === 443) || port === 80)) { headers.Host += ':' + url.port } // Set Basic Auth if necessary if (settings.user) { if (typeof settings.password === 'undefined') { settings.password = '' } const authBuf = Buffer.from(settings.user + ':' + settings.password) headers.Authorization = 'Basic ' + authBuf.toString('base64') } // Set content length header if (settings.method === 'GET' || settings.method === 'HEAD') { data = null } else if (data) { headers['Content-Length'] = Buffer.byteLength(data) if (!headers['Content-Type']) { headers['Content-Type'] = 'text/plain;charset=UTF-8' } } else if (settings.method === 'POST') { // For a post with no data set Content-Length: 0. // This is required by buggy servers that don't meet the specs. headers['Content-Length'] = 0 } const options = { host, port, path: uri, method: settings.method, headers, } // Reset error flag errorFlag = false // Handle async requests if (settings.async) { // Use the proper protocol const doRequest = ssl ? https.request : http.request // Request is being sent, set send flag sendFlag = true // As per spec, this is called here for historical reasons. self.dispatchEvent('readystatechange') // Create the request request = doRequest(options, resp => { response = resp response.setEncoding('utf8') setState(self.HEADERS_RECEIVED) self.status = response.statusCode response.on('data', chunk => { // Make sure there's some data if (chunk) { self.responseText += chunk } // Don't emit state changes if the connection has been aborted. if (sendFlag) { setState(self.LOADING) } }) response.on('end', () => { if (sendFlag) { // Discard the 'end' event if the connection has been aborted setState(self.DONE) sendFlag = false } }) response.on('error', error => { self.handleError(error) }) }).on('error', error => { self.handleError(error) }) // Node 0.4 and later won't accept empty data. Make sure it's needed. if (data) { request.write(data) } request.end() self.dispatchEvent('loadstart') } else { // Synchronous // Create a temporary file for communication with the other Node process const syncFile = '.node-xmlhttprequest-sync-' + process.pid fs.writeFileSync(syncFile, '', 'utf8') // The async request the other Node process executes const execString = "var http = require('http'), https = require('https'), fs = require('fs');" + 'var doRequest = http' + (ssl ? 's' : '') + '.request;' + 'var options = ' + JSON.stringify(options) + ';' + "var responseText = '';" + 'var req = doRequest(options, function(response) {' + "response.setEncoding('utf8');" + "response.on('data', function(chunk) {" + 'responseText += chunk;' + '});' + "response.on('end', function() {" + "fs.writeFileSync('" + syncFile + "', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8');" + '});' + "response.on('error', function(error) {" + "fs.writeFileSync('" + syncFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + '});' + "}).on('error', function(error) {" + "fs.writeFileSync('" + syncFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + '});' + (data ? "req.write('" + data.replace(/'/g, "\\'") + "');" : '') + 'req.end();' // Start the other Node Process, executing this string const syncProc = spawn(process.argv[0], ['-e', execString]) while ((self.responseText = fs.readFileSync(syncFile, 'utf8')) === '') { // Wait while the file is empty } // Kill the child process once the file has data syncProc.stdin.end() // Remove the temporary file fs.unlinkSync(syncFile) if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) { // If the file returned an error, handle it const errorObj = self.responseText.replace( /^NODE-XMLHTTPREQUEST-ERROR:/, '' ) self.handleError(errorObj) } else { // If the file returned okay, parse its data and move to the DONE state self.status = self.responseText.replace( /^NODE-XMLHTTPREQUEST-STATUS:([0-9]*),.*/, '$1' ) self.responseText = self.responseText.replace( /^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, '$1' ) setState(self.DONE) } } } /** * Called when an error is encountered to deal with it. */ this.handleError = function (error) { this.status = 503 this.statusText = error this.responseText = error.stack errorFlag = true setState(this.DONE) } /** * Aborts a request. */ this.abort = function () { if (request) { request.abort() request = null } headers = defaultHeaders this.responseText = '' this.responseXML = '' errorFlag = true if ( this.readyState !== this.UNSENT && (this.readyState !== this.OPENED || sendFlag) && this.readyState !== this.DONE ) { sendFlag = false setState(this.DONE) } this.readyState = this.UNSENT } /** * Adds an event listener. Preferred method of binding to events. */ this.addEventListener = function (event, callback) { if (!(event in listeners)) { listeners[event] = [] } // Currently allows duplicate callbacks. Should it? listeners[event].push(callback) } /** * Remove an event callback that has already been bound. * Only works on the matching funciton, cannot be a copy. */ this.removeEventListener = function (event, callback) { if (event in listeners) { // Filter will return a new array with the callback removed listeners[event] = listeners[event].filter(ev => { return ev !== callback }) } } /** * Dispatch any events, including both "on" methods and events attached using addEventListener. */ this.dispatchEvent = function (event) { if (typeof self['on' + event] === 'function') { self['on' + event]() } if (event in listeners) { for (let i = 0, len = listeners[event].length; i < len; i++) { listeners[event][i].call(self) } } } /** * Changes readyState and calls onreadystatechange. * * @param int state New state */ function setState(state) { if (self.readyState !== state) { self.readyState = state if ( settings.async || self.readyState < self.OPENED || self.readyState === self.DONE ) { self.dispatchEvent('readystatechange') } if (self.readyState === self.DONE && !errorFlag) { self.dispatchEvent('load') // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) self.dispatchEvent('loadend') } } } }