Bug 1286281 - Part 3: WebSocket server for WebSocket remote debugger transport r=jryans
authorJarda Snajdr <jsnajdr@gmail.com>
Fri, 05 Aug 2016 05:00:00 -0700
changeset 308333 05299f77fe3865660fb8e8eac46784ab9bb40ea0
parent 308332 03f498db6539c25a8c1d087fd058f26ccb933fe7
child 308334 7a112d070bbf534ac611ab24b8d724c20273aa1e
push id20233
push userntim.bugs@gmail.com
push dateFri, 05 Aug 2016 13:51:40 +0000
treeherderfx-team@bfb4f7467dd4 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjryans
bugs1286281
milestone51.0a1
Bug 1286281 - Part 3: WebSocket server for WebSocket remote debugger transport r=jryans MozReview-Commit-ID: DFcPTayju7m
.eslintignore
devtools/.eslintrc
devtools/server/moz.build
devtools/server/websocket-server.js
devtools/shared/builtin-modules.js
--- a/.eslintignore
+++ b/.eslintignore
@@ -109,16 +109,17 @@ devtools/client/webconsole/**
 !devtools/client/webconsole/console-commands.js
 devtools/client/webide/**
 !devtools/client/webide/components/webideCli.js
 devtools/server/*.js
 devtools/server/*.jsm
 !devtools/server/child.js
 !devtools/server/css-logic.js
 !devtools/server/main.js
+!devtools/server/websocket-server.js
 devtools/server/actors/**
 !devtools/server/actors/inspector.js
 !devtools/server/actors/highlighters/eye-dropper.js
 !devtools/server/actors/webbrowser.js
 !devtools/server/actors/webextension.js
 !devtools/server/actors/styles.js
 !devtools/server/actors/string.js
 !devtools/server/actors/csscoverage.js
--- a/devtools/.eslintrc
+++ b/devtools/.eslintrc
@@ -17,16 +17,17 @@
     "loader": true,
     "module": true,
     "reportError": true,
     "require": true,
     "setInterval": true,
     "setTimeout": true,
     "uneval": true,
     "URL": true,
+    "WebSocket": true,
     "XMLHttpRequest": true,
     "_Iterator": true,
   },
   "rules": {
     // These are the rules that have been configured so far to match the
     // devtools coding style.
 
     // Rules from the mozilla plugin
--- a/devtools/server/moz.build
+++ b/devtools/server/moz.build
@@ -32,10 +32,11 @@ DevToolsModules(
     'child.js',
     'content-globals.js',
     'content-server.jsm',
     'css-logic.js',
     'event-parsers.js',
     'main.js',
     'primitive.js',
     'service-worker-child.js',
+    'websocket-server.js',
     'worker.js'
 )
new file mode 100644
--- /dev/null
+++ b/devtools/server/websocket-server.js
@@ -0,0 +1,221 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { Cc, CC } = require("chrome");
+const Promise = require("promise");
+const { Task } = require("devtools/shared/task");
+const { executeSoon } = require("devtools/shared/DevToolsUtils");
+const { delimitedRead } = require("devtools/shared/transport/stream-utils");
+const CryptoHash = CC("@mozilla.org/security/hash;1", "nsICryptoHash", "initWithString");
+const threadManager = Cc["@mozilla.org/thread-manager;1"].getService();
+
+// Limit the header size to put an upper bound on allocated memory
+const HEADER_MAX_LEN = 8000;
+
+/**
+ * Read a line from async input stream and return promise that resolves to the line once
+ * it has been read. If the line is longer than HEADER_MAX_LEN, will throw error.
+ */
+function readLine(input) {
+  return new Promise((resolve, reject) => {
+    let line = "";
+    let wait = () => {
+      input.asyncWait(stream => {
+        try {
+          let amountToRead = HEADER_MAX_LEN - line.length;
+          line += delimitedRead(input, "\n", amountToRead);
+
+          if (line.endsWith("\n")) {
+            resolve(line.trimRight());
+            return;
+          }
+
+          if (line.length >= HEADER_MAX_LEN) {
+            throw new Error(
+              `Failed to read HTTP header longer than ${HEADER_MAX_LEN} bytes`);
+          }
+
+          wait();
+        } catch (ex) {
+          reject(ex);
+        }
+      }, 0, 0, threadManager.currentThread);
+    };
+
+    wait();
+  });
+}
+
+/**
+ * Write a string of bytes to async output stream and return promise that resolves once
+ * all data has been written. Doesn't do any utf-16/utf-8 conversion - the string is
+ * treated as an array of bytes.
+ */
+function writeString(output, data) {
+  return new Promise((resolve, reject) => {
+    let wait = () => {
+      if (data.length === 0) {
+        resolve();
+        return;
+      }
+
+      output.asyncWait(stream => {
+        try {
+          let written = output.write(data, data.length);
+          data = data.slice(written);
+          wait();
+        } catch (ex) {
+          reject(ex);
+        }
+      }, 0, 0, threadManager.currentThread);
+    };
+
+    wait();
+  });
+}
+
+/**
+ * Read HTTP request from async input stream.
+ * @return Request line (string) and Map of header names and values.
+ */
+const readHttpRequest = Task.async(function* (input) {
+  let requestLine = "";
+  let headers = new Map();
+
+  while (true) {
+    let line = yield readLine(input);
+    if (line.length == 0) {
+      break;
+    }
+
+    if (!requestLine) {
+      requestLine = line;
+    } else {
+      let colon = line.indexOf(":");
+      if (colon == -1) {
+        throw new Error(`Malformed HTTP header: ${line}`);
+      }
+
+      let name = line.slice(0, colon).toLowerCase();
+      let value = line.slice(colon + 1).trim();
+      headers.set(name, value);
+    }
+  }
+
+  return { requestLine, headers };
+});
+
+/**
+ * Write HTTP response (array of strings) to async output stream.
+ */
+function writeHttpResponse(output, response) {
+  let responseString = response.join("\r\n") + "\r\n\r\n";
+  return writeString(output, responseString);
+}
+
+/**
+ * Process the WebSocket handshake headers and return the key to be sent in
+ * Sec-WebSocket-Accept response header.
+ */
+function processRequest({ requestLine, headers }) {
+  let [ method, path ] = requestLine.split(" ");
+  if (method !== "GET") {
+    throw new Error("The handshake request must use GET method");
+  }
+
+  if (path !== "/") {
+    throw new Error("The handshake request has unknown path");
+  }
+
+  let upgrade = headers.get("upgrade");
+  if (!upgrade || upgrade !== "websocket") {
+    throw new Error("The handshake request has incorrect Upgrade header");
+  }
+
+  let connection = headers.get("connection");
+  if (!connection || !connection.split(",").map(t => t.trim()).includes("Upgrade")) {
+    throw new Error("The handshake request has incorrect Connection header");
+  }
+
+  let version = headers.get("sec-websocket-version");
+  if (!version || version !== "13") {
+    throw new Error("The handshake request must have Sec-WebSocket-Version: 13");
+  }
+
+  // Compute the accept key
+  let key = headers.get("sec-websocket-key");
+  if (!key) {
+    throw new Error("The handshake request must have a Sec-WebSocket-Key header");
+  }
+
+  return { acceptKey: computeKey(key) };
+}
+
+function computeKey(key) {
+  let str = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
+
+  let data = Array.from(str, ch => ch.charCodeAt(0));
+  let hash = new CryptoHash("sha1");
+  hash.update(data, data.length);
+  return hash.finish(true);
+}
+
+/**
+ * Perform the server part of a WebSocket opening handshake on an incoming connection.
+ */
+const serverHandshake = Task.async(function* (input, output) {
+  // Read the request
+  let request = yield readHttpRequest(input);
+
+  try {
+    // Check and extract info from the request
+    let { acceptKey } = processRequest(request);
+
+    // Send response headers
+    yield writeHttpResponse(output, [
+      "HTTP/1.1 101 Switching Protocols",
+      "Upgrade: websocket",
+      "Connection: Upgrade",
+      `Sec-WebSocket-Accept: ${acceptKey}`,
+    ]);
+  } catch (error) {
+    // Send error response in case of error
+    yield writeHttpResponse(output, [ "HTTP/1.1 400 Bad Request" ]);
+    throw error;
+  }
+});
+
+/**
+ * Accept an incoming WebSocket server connection.
+ * Takes an established nsISocketTransport in the parameters.
+ * Performs the WebSocket handshake and waits for the WebSocket to open.
+ * Returns Promise with a WebSocket ready to send and receive messages.
+ */
+const accept = Task.async(function* (transport, input, output) {
+  yield serverHandshake(input, output);
+
+  let transportProvider = {
+    setListener(upgradeListener) {
+      // The onTransportAvailable callback shouldn't be called synchronously.
+      executeSoon(() => {
+        upgradeListener.onTransportAvailable(transport, input, output);
+      });
+    }
+  };
+
+  return new Promise((resolve, reject) => {
+    let socket = WebSocket.createServerWebSocket(null, [], transportProvider, "");
+    socket.addEventListener("close", () => {
+      input.close();
+      output.close();
+    });
+
+    socket.onopen = () => resolve(socket);
+    socket.onerror = err => reject(err);
+  });
+});
+
+exports.accept = accept;
--- a/devtools/shared/builtin-modules.js
+++ b/devtools/shared/builtin-modules.js
@@ -288,8 +288,11 @@ defineLazyGetter(globals, "clearInterval
 });
 defineLazyGetter(globals, "setInterval", () => {
   return Cu.import("resource://gre/modules/Timer.jsm", {}).setInterval;
 });
 defineLazyGetter(globals, "CSSRule", () => Ci.nsIDOMCSSRule);
 defineLazyGetter(globals, "DOMParser", () => {
   return CC("@mozilla.org/xmlextras/domparser;1", "nsIDOMParser");
 });
+defineLazyGetter(globals, "WebSocket", () => {
+  return Services.appShell.hiddenDOMWindow.WebSocket;
+});