From fbf983c2ff88c339bf6ed962d6aebe37d9cfb31b Mon Sep 17 00:00:00 2001 From: James Allen Date: Fri, 7 Nov 2014 17:38:12 +0000 Subject: [PATCH] Create framework for real-time API with session authentication --- services/real-time/.gitignore | 5 + services/real-time/Gruntfile.coffee | 74 +++ services/real-time/app.coffee | 50 ++ services/real-time/app/coffee/Router.coffee | 20 + services/real-time/client.coffee | 0 .../real-time/config/settings.defaults.coffee | 16 + services/real-time/package.json | 39 ++ .../acceptance/coffee/SessionTests.coffee | 47 ++ .../coffee/helpers/RealTimeClient.coffee | 40 ++ .../test/acceptance/libs/XMLHttpRequest.js | 548 ++++++++++++++++++ 10 files changed, 839 insertions(+) create mode 100644 services/real-time/.gitignore create mode 100644 services/real-time/Gruntfile.coffee create mode 100644 services/real-time/app.coffee create mode 100644 services/real-time/app/coffee/Router.coffee create mode 100644 services/real-time/client.coffee create mode 100644 services/real-time/config/settings.defaults.coffee create mode 100644 services/real-time/package.json create mode 100644 services/real-time/test/acceptance/coffee/SessionTests.coffee create mode 100644 services/real-time/test/acceptance/coffee/helpers/RealTimeClient.coffee create mode 100644 services/real-time/test/acceptance/libs/XMLHttpRequest.js diff --git a/services/real-time/.gitignore b/services/real-time/.gitignore new file mode 100644 index 0000000000..e3b29a58ff --- /dev/null +++ b/services/real-time/.gitignore @@ -0,0 +1,5 @@ +node_modules +app.js +app/js +test/unit/js +test/acceptance/js \ No newline at end of file diff --git a/services/real-time/Gruntfile.coffee b/services/real-time/Gruntfile.coffee new file mode 100644 index 0000000000..e01c8aecb1 --- /dev/null +++ b/services/real-time/Gruntfile.coffee @@ -0,0 +1,74 @@ +module.exports = (grunt) -> + grunt.initConfig + forever: + app: + options: + index: "app.js" + coffee: + app_src: + expand: true, + flatten: true, + cwd: "app" + src: ['coffee/*.coffee'], + dest: 'app/js/', + ext: '.js' + + app: + src: "app.coffee" + dest: "app.js" + + unit_tests: + expand: true + cwd: "test/unit/coffee" + src: ["**/*.coffee"] + dest: "test/unit/js/" + ext: ".js" + + acceptance_tests: + expand: true + cwd: "test/acceptance/coffee" + src: ["**/*.coffee"] + dest: "test/acceptance/js/" + ext: ".js" + clean: + app: ["app/js/"] + unit_tests: ["test/unit/js"] + acceptance_tests: ["test/acceptance/js"] + smoke_tests: ["test/smoke/js"] + + execute: + app: + src: "app.js" + + mochaTest: + unit: + options: + reporter: grunt.option('reporter') or 'spec' + src: ["test/unit/js/**/*.js"] + acceptance: + options: + reporter: grunt.option('reporter') or 'spec' + timeout: 40000 + grep: grunt.option("grep") + src: ["test/acceptance/js/**/*.js"] + + grunt.loadNpmTasks 'grunt-contrib-coffee' + grunt.loadNpmTasks 'grunt-contrib-clean' + grunt.loadNpmTasks 'grunt-mocha-test' + grunt.loadNpmTasks 'grunt-shell' + grunt.loadNpmTasks 'grunt-execute' + grunt.loadNpmTasks 'grunt-bunyan' + grunt.loadNpmTasks 'grunt-forever' + + grunt.registerTask 'compile:app', ['clean:app', 'coffee:app', 'coffee:app_src'] + grunt.registerTask 'run', ['compile:app', 'bunyan', 'execute'] + + grunt.registerTask 'compile:unit_tests', ['clean:unit_tests', 'coffee:unit_tests'] + grunt.registerTask 'test:unit', ['compile:app', 'compile:unit_tests', 'mochaTest:unit'] + + grunt.registerTask 'compile:acceptance_tests', ['clean:acceptance_tests', 'coffee:acceptance_tests'] + grunt.registerTask 'test:acceptance', ['compile:acceptance_tests', 'mochaTest:acceptance'] + + grunt.registerTask 'install', 'compile:app' + + grunt.registerTask 'default', ['run'] \ No newline at end of file diff --git a/services/real-time/app.coffee b/services/real-time/app.coffee new file mode 100644 index 0000000000..15097f51b8 --- /dev/null +++ b/services/real-time/app.coffee @@ -0,0 +1,50 @@ +express = require("express") +session = require("express-session") +redis = require("redis-sharelatex") +RedisStore = require('connect-redis')(session) +SessionSockets = require('session.socket.io') +CookieParser = require("cookie-parser") + +Settings = require "settings-sharelatex" + +logger = require "logger-sharelatex" +logger.initialize("real-time-sharelatex") + +Metrics = require("metrics-sharelatex") +Metrics.initialize("real-time") + +rclient = redis.createClient(Settings.redis.web) + +# Set up socket.io server +app = express() +server = require('http').createServer(app) +io = require('socket.io').listen(server) + +# Bind to sessions +sessionStore = new RedisStore(client: rclient) +cookieParser = CookieParser(Settings.security.sessionSecret) +sessionSockets = new SessionSockets(io, sessionStore, cookieParser, Settings.cookieName) + +io.configure -> + io.enable('browser client minification') + io.enable('browser client etag') + + # Fix for Safari 5 error of "Error during WebSocket handshake: location mismatch" + # See http://answers.dotcloud.com/question/578/problem-with-websocket-over-ssl-in-safari-with + io.set('match origin protocol', true) + + # gzip uses a Node 0.8.x method of calling the gzip program which + # doesn't work with 0.6.x + #io.enable('browser client gzip') + io.set('transports', ['websocket', 'flashsocket', 'htmlfile', 'xhr-polling', 'jsonp-polling']) + io.set('log level', 1) + +Router = require "./app/js/Router" +Router.configure(app, io, sessionSockets) + +port = Settings.internal.realTime.port +host = Settings.internal.realTime.host + +server.listen port, host, (error) -> + throw error if error? + logger.log "real-time-sharelatex listening on #{host}:#{port}" \ No newline at end of file diff --git a/services/real-time/app/coffee/Router.coffee b/services/real-time/app/coffee/Router.coffee new file mode 100644 index 0000000000..fc363d30ee --- /dev/null +++ b/services/real-time/app/coffee/Router.coffee @@ -0,0 +1,20 @@ +Metrics = require "metrics-sharelatex" +logger = require "logger-sharelatex" + +module.exports = Router = + configure: (app, io, session) -> + session.on 'connection', (error, client, session) -> + if error? + logger.err err: error, "error when client connected" + client?.disconnect() + return + + Metrics.inc('socket-io.connection') + + logger.log session: session, "got session" + + user = session.user + if !user? + logger.log "terminating session without authenticated user" + client.disconnect() + return \ No newline at end of file diff --git a/services/real-time/client.coffee b/services/real-time/client.coffee new file mode 100644 index 0000000000..e69de29bb2 diff --git a/services/real-time/config/settings.defaults.coffee b/services/real-time/config/settings.defaults.coffee new file mode 100644 index 0000000000..7b069975b1 --- /dev/null +++ b/services/real-time/config/settings.defaults.coffee @@ -0,0 +1,16 @@ +module.exports = + redis: + web: + host: "localhost" + port: "6379" + password: "" + + internal: + realTime: + port: 3026 + host: "localhost" + + security: + sessionSecret: "secret-please-change" + + cookieName:"sharelatex.sid" \ No newline at end of file diff --git a/services/real-time/package.json b/services/real-time/package.json new file mode 100644 index 0000000000..01b664e945 --- /dev/null +++ b/services/real-time/package.json @@ -0,0 +1,39 @@ +{ + "name": "real-time-sharelatex", + "version": "0.0.1", + "description": "The socket.io layer of ShareLaTeX for real-time editor interactions", + "author": "ShareLaTeX ", + "repository": { + "type": "git", + "url": "https://github.com/sharelatex/real-time-sharelatex.git" + }, + "dependencies": { + "connect-redis": "^2.1.0", + "express": "^4.10.1", + "express-session": "^1.9.1", + "logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#v1.0.0", + "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.0.0", + "redis-sharelatex": "~0.0.4", + "session.socket.io": "^0.1.6", + "settings-sharelatex": "git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0", + "socket.io": "0.9.16", + "socket.io-client": "^0.9.16" + }, + "devDependencies": { + "bunyan": "~0.22.3", + "chai": "~1.9.1", + "cookie-signature": "^1.0.5", + "grunt": "~0.4.4", + "grunt-bunyan": "~0.5.0", + "grunt-contrib-clean": "~0.5.0", + "grunt-contrib-coffee": "~0.10.1", + "grunt-execute": "~0.2.1", + "grunt-forever": "~0.4.4", + "grunt-mocha-test": "~0.10.2", + "grunt-shell": "~0.7.0", + "request": "~2.34.0", + "sandboxed-module": "~0.3.0", + "sinon": "~1.5.2", + "uid-safe": "^1.0.1" + } +} diff --git a/services/real-time/test/acceptance/coffee/SessionTests.coffee b/services/real-time/test/acceptance/coffee/SessionTests.coffee new file mode 100644 index 0000000000..01b31cee01 --- /dev/null +++ b/services/real-time/test/acceptance/coffee/SessionTests.coffee @@ -0,0 +1,47 @@ +chai = require("chai") +expect = chai.expect + +RealTimeClient = require "./helpers/RealTimeClient" + +describe "Session", -> + describe "with an established session", -> + beforeEach (done) -> + RealTimeClient.setSession { + user: { _id: @user_id } + }, (error) => + throw error if error? + @client = RealTimeClient.connect() + done() + + it "should not get disconnected", (done) -> + disconnected = false + @client.on "disconnect", () -> + disconnected = true + setTimeout () => + expect(disconnected).to.equal false + done() + , 500 + + describe "without an established session", -> + beforeEach (done) -> + RealTimeClient.unsetSession (error) => + throw error if error? + @client = RealTimeClient.connect() + done() + + it "should get disconnected", (done) -> + @client.on "disconnect", () -> + done() + + describe "with a user set on the session", -> + beforeEach (done) -> + RealTimeClient.setSession { + foo: "bar" + }, (error) => + throw error if error? + @client = RealTimeClient.connect() + done() + + it "should get disconnected", (done) -> + @client.on "disconnect", () -> + done() \ No newline at end of file diff --git a/services/real-time/test/acceptance/coffee/helpers/RealTimeClient.coffee b/services/real-time/test/acceptance/coffee/helpers/RealTimeClient.coffee new file mode 100644 index 0000000000..415f14fe5e --- /dev/null +++ b/services/real-time/test/acceptance/coffee/helpers/RealTimeClient.coffee @@ -0,0 +1,40 @@ +XMLHttpRequest = require("../../libs/XMLHttpRequest").XMLHttpRequest +io = require("socket.io-client") + +Settings = require "settings-sharelatex" +redis = require "redis-sharelatex" +rclient = redis.createClient(Settings.redis.web) + +uid = require('uid-safe').sync +signature = require("cookie-signature") + +io.util.request = () -> + xhr = new XMLHttpRequest() + _open = xhr.open + xhr.open = () => + _open.apply(xhr, arguments) + if Client.cookie? + xhr.setRequestHeader("Cookie", Client.cookie) + return xhr + +module.exports = Client = + cookie: null + + setSession: (session, callback = (error) ->) -> + sessionId = uid(24) + session.cookie = {} + rclient.set "sess:" + sessionId, JSON.stringify(session), (error) -> + return callback(error) if error? + secret = Settings.security.sessionSecret + cookieKey = 's:' + signature.sign(sessionId, secret) + Client.cookie = "#{Settings.cookieName}=#{cookieKey}" + callback() + + unsetSession: (callback = (error) ->) -> + Client.cookie = null + callback() + + connect: (cookie) -> + client = io.connect("http://localhost:3026", 'force new connection': true) + return client + diff --git a/services/real-time/test/acceptance/libs/XMLHttpRequest.js b/services/real-time/test/acceptance/libs/XMLHttpRequest.js new file mode 100644 index 0000000000..e79634da5e --- /dev/null +++ b/services/real-time/test/acceptance/libs/XMLHttpRequest.js @@ -0,0 +1,548 @@ +/** + * 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 + */ + +var Url = require("url") + , spawn = require("child_process").spawn + , fs = require('fs'); + +exports.XMLHttpRequest = function() { + /** + * Private variables + */ + var self = this; + var http = require('http'); + var https = require('https'); + + // Holds http.js objects + var client; + var request; + var response; + + // Request settings + var settings = {}; + + // Set some default headers + var defaultHeaders = { + "User-Agent": "node-XMLHttpRequest", + "Accept": "*/*", + }; + + var headers = defaultHeaders; + + // These headers are not user setable. + // The following are allowed but banned in the spec: + // * user-agent + var 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 + var forbiddenRequestMethods = [ + "TRACE", + "TRACK", + "CONNECT" + ]; + + // Send flag + var sendFlag = false; + // Error flag, used when errors occur or abort is called + var errorFlag = false; + + // Event listeners + var 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 + */ + var 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 + */ + var 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 "SecurityError: Request method not allowed"; + return; + } + + settings = { + "method": 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 "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 "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 ""; + } + var result = ""; + + for (var 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 "INVALID_STATE_ERR: connection must be opened before send() is called"; + } + + if (sendFlag) { + throw "INVALID_STATE_ERR: send has already been called"; + } + + var ssl = false, local = false; + var url = Url.parse(settings.url); + + // Determine the server + switch (url.protocol) { + case 'https:': + ssl = true; + // SSL & non-SSL both need host, no break here. + case 'http:': + var host = url.hostname; + break; + + case 'file:': + local = true; + break; + + case undefined: + case '': + var host = "localhost"; + break; + + default: + throw "Protocol not supported."; + } + + // Load files off the local filesystem (file://) + if (local) { + if (settings.method !== "GET") { + throw "XMLHttpRequest: Only GET method is supported"; + } + + if (settings.async) { + fs.readFile(url.pathname, 'utf8', function(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 localhost on another port be sure + // to use http://localhost:port/path + var port = url.port || (ssl ? 443 : 80); + // Add query string if one is used + var 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 = ""; + } + var authBuf = new Buffer(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; + } + + var options = { + host: host, + port: port, + path: uri, + method: settings.method, + headers: headers + }; + + // Reset error flag + errorFlag = false; + + // Handle async requests + if (settings.async) { + // Use the proper protocol + var 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, function(resp) { + response = resp; + response.setEncoding("utf8"); + + setState(self.HEADERS_RECEIVED); + self.status = response.statusCode; + + response.on('data', function(chunk) { + // Make sure there's some data + if (chunk) { + self.responseText += chunk; + } + // Don't emit state changes if the connection has been aborted. + if (sendFlag) { + setState(self.LOADING); + } + }); + + response.on('end', function() { + if (sendFlag) { + // Discard the 'end' event if the connection has been aborted + setState(self.DONE); + sendFlag = false; + } + }); + + response.on('error', function(error) { + self.handleError(error); + }); + }).on('error', function(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 + var syncFile = ".node-xmlhttprequest-sync-" + process.pid; + fs.writeFileSync(syncFile, "", "utf8"); + // The async request the other Node process executes + var execString = "var http = require('http'), https = require('https'), fs = require('fs');" + + "var 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 + 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 + var 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(function(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 (var i = 0, len = listeners[event].length; i < len; i++) { + listeners[event][i].call(self); + } + } + }; + + /** + * Changes readyState and calls onreadystatechange. + * + * @param int state New state + */ + var setState = function(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"); + } + } + }; +};