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 349771 05299f77fe3865660fb8e8eac46784ab9bb40ea0
parent 349770 03f498db6539c25a8c1d087fd058f26ccb933fe7
child 349772 7a112d070bbf534ac611ab24b8d724c20273aa1e
push id6570
push userraliiev@mozilla.com
push dateMon, 14 Nov 2016 12:26:13 +0000
treeherdermozilla-beta@f455459b2ae5 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjryans
bugs1286281
milestone51.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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;
+});