Просмотр исходного кода

Create framework for real-time API with session authentication

James Allen 12 лет назад
Сommit
fbf983c2ff

+ 5 - 0
services/real-time/.gitignore

@@ -0,0 +1,5 @@
+node_modules
+app.js
+app/js
+test/unit/js
+test/acceptance/js

+ 74 - 0
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']

+ 50 - 0
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}"

+ 20 - 0
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

+ 0 - 0
services/real-time/client.coffee


+ 16 - 0
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"

+ 39 - 0
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 <team@sharelatex.com>",
+  "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"
+  }
+}

+ 47 - 0
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()

+ 40 - 0
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
+

+ 548 - 0
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 <dan@driverdan.com>
+ * @contributor David Ellis <d.f.ellis@ieee.org>
+ * @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");
+      }
+    }
+  };
+};