Add a shelf_web_socket package.

R=kevmoo@google.com

Review URL: https://codereview.chromium.org//297593003

git-svn-id: https://dart.googlecode.com/svn/branches/bleeding_edge/dart/pkg/shelf_web_socket@36394 260f80e4-7a28-3924-810f-c04153c831b5
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5c60afe
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,26 @@
+Copyright 2014, the Dart project authors. All rights reserved.
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+    * Neither the name of Google Inc. nor the names of its
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9091de0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,31 @@
+## Web Socket Handler for Shelf
+
+`shelf_web_socket` is a [Shelf][] handler for establishing [WebSocket][]
+connections. It exposes a single function, [webSocketHandler][], which calls an
+`onConnection` callback with a [CompatibleWebSocket][] object for every
+connection that's established.
+
+[Shelf]: pub.dartlang.org/packages/shelf
+
+[WebSocket]: https://tools.ietf.org/html/rfc6455
+
+[webSocketHandler]: https://api.dartlang.org/apidocs/channels/be/dartdoc-viewer/shelf_web_socket/shelf_web_socket.webSocketHandler
+
+[CompatibleWebSocket]: https://api.dartlang.org/apidocs/channels/be/dartdoc-viewer/http_parser/http_parser.CompatibleWebSocket
+
+```dart
+import 'package:shelf/shelf_io.dart' as shelf_io;
+import 'package:shelf_web_socket/shelf_web_socket.dart';
+
+void main() {
+  var handler = webSocketHandler((webSocket) {
+    webSocket.listen((message) {
+      webSocket.add("echo $message");
+    });
+  });
+
+  shelf_io.serve(handler, 'localhost', 8080).then((server) {
+    print('Serving at ws://${server.address.host}:${server.port}');
+  });
+}
+```
diff --git a/lib/shelf_web_socket.dart b/lib/shelf_web_socket.dart
new file mode 100644
index 0000000..8aac366
--- /dev/null
+++ b/lib/shelf_web_socket.dart
@@ -0,0 +1,61 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library shelf_web_socket;
+
+import 'package:shelf/shelf.dart';
+
+import 'src/web_socket_handler.dart';
+
+/// A typedef used to determine if a function takes two arguments or not.
+typedef _BinaryFunction(arg1, arg2);
+
+/// Creates a Shelf handler that upgrades HTTP requests to WebSocket
+/// connections.
+///
+/// Only valid WebSocket upgrade requests are upgraded. If a request doesn't
+/// look like a WebSocket upgrade request, a 404 Not Found is returned; if a
+/// request looks like an upgrade request but is invalid, a 400 Bad Request is
+/// returned; and if a request is a valid upgrade request but has an origin that
+/// doesn't match [allowedOrigins] (see below), a 403 Forbidden is returned.
+/// This means that this can be placed first in a [Cascade] and only upgrade
+/// requests will be handled.
+///
+/// The [onConnection] must take a [CompatibleWebSocket] as its first argument.
+/// It may also take a string, the [WebSocket subprotocol][], as its second
+/// argument. The subprotocol is determined by looking at the client's
+/// `Sec-WebSocket-Protocol` header and selecting the first entry that also
+/// appears in [protocols]. If no subprotocols are shared between the client and
+/// the server, `null` will be passed instead. Note that if [onConnection] takes
+/// two arguments, [protocols] must be passed.
+///
+/// [WebSocket subprotocol]: https://tools.ietf.org/html/rfc6455#section-1.9
+///
+/// If [allowedOrigins] is passed, browser connections will only be accepted if
+/// they're made by a script from one of the given origins. This ensures that
+/// malicious scripts running in the browser are unable to fake a WebSocket
+/// handshake. Note that non-browser programs can still make connections freely.
+/// See also the WebSocket spec's discussion of [origin considerations][].
+///
+/// [origin considerations]: https://tools.ietf.org/html/rfc6455#section-10.2
+Handler webSocketHandler(Function onConnection, {Iterable<String> protocols,
+      Iterable<String> allowedOrigins}) {
+  if (protocols != null) protocols = protocols.toSet();
+  if (allowedOrigins != null) {
+    allowedOrigins = allowedOrigins
+        .map((origin) => origin.toLowerCase()).toSet();
+  }
+
+  if (onConnection is! _BinaryFunction) {
+    if (protocols != null) {
+      throw new ArgumentError("If protocols is non-null, onConnection must "
+          "take two arguments, the WebSocket and the protocol.");
+    }
+
+    var innerOnConnection = onConnection;
+    onConnection = (webSocket, _) => innerOnConnection(webSocket);
+  }
+
+  return new WebSocketHandler(onConnection, protocols, allowedOrigins).handle;
+}
diff --git a/lib/src/web_socket_handler.dart b/lib/src/web_socket_handler.dart
new file mode 100644
index 0000000..ac5786a
--- /dev/null
+++ b/lib/src/web_socket_handler.dart
@@ -0,0 +1,133 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library shelf_web_socket.web_socket_handler;
+
+import 'dart:convert';
+
+import 'package:http_parser/http_parser.dart';
+import 'package:shelf/shelf.dart';
+
+/// A class that exposes a handler for upgrading WebSocket requests.
+class WebSocketHandler {
+  /// The function to call when a request is upgraded.
+  final Function _onConnection;
+
+  /// The set of protocols the user supports, or `null`.
+  final Set<String> _protocols;
+
+  /// The set of allowed browser origin connections, or `null`..
+  final Set<String> _allowedOrigins;
+
+  WebSocketHandler(this._onConnection, this._protocols, this._allowedOrigins);
+
+  /// The [Handler].
+  Response handle(Request request) {
+    if (request.method != 'GET') return _notFound();
+
+    var connection = request.headers['Connection'];
+    if (connection == null) return _notFound();
+    if (connection.toLowerCase() != 'upgrade') return _notFound();
+
+    var upgrade = request.headers['Upgrade'];
+    if (upgrade == null) return _notFound();
+    if (upgrade.toLowerCase() != 'websocket') return _notFound();
+
+    var version = request.headers['Sec-WebSocket-Version'];
+    if (version == null) {
+      return _badRequest('missing Sec-WebSocket-Version header.');
+    } else if (version != '13') {
+      return _notFound();
+    }
+
+    if (request.protocolVersion != '1.1') {
+      return _badRequest('unexpected HTTP version '
+          '"${request.protocolVersion}".');
+    }
+
+    var key = request.headers['Sec-WebSocket-Key'];
+    if (key == null) return _badRequest('missing Sec-WebSocket-Key header.');
+
+    if (!request.canHijack) {
+      throw new ArgumentError("webSocketHandler may only be used with a server "
+          "that supports request hijacking.");
+    }
+
+    // The Origin header is always set by browser connections. By filtering out
+    // unexpected origins, we ensure that malicious JavaScript is unable to fake
+    // a WebSocket handshake.
+    var origin = request.headers['Origin'];
+    if (origin != null && _allowedOrigins != null &&
+        !_allowedOrigins.contains(origin.toLowerCase())) {
+      return _forbidden('invalid origin "$origin".');
+    }
+
+    var protocol = _chooseProtocol(request);
+    request.hijack((stream, byteSink) {
+      var sink = UTF8.encoder.startChunkedConversion(byteSink);
+      sink.add(
+          "HTTP/1.1 101 Switching Protocols\r\n"
+          "Upgrade: websocket\r\n"
+          "Connection: Upgrade\r\n"
+          "Sec-WebSocket-Accept: ${CompatibleWebSocket.signKey(key)}\r\n");
+      if (protocol != null) sink.add("Sec-WebSocket-Protocol: $protocol\r\n");
+      sink.add("\r\n");
+
+      _onConnection(new CompatibleWebSocket(stream, sink: byteSink), protocol);
+    });
+
+    // [request.hijack] is guaranteed to throw a [HijackException], so we'll
+    // never get here.
+    assert(false);
+    return null;
+  }
+
+  /// Selects a subprotocol to use for the given connection.
+  ///
+  /// If no matching protocol can be found, returns `null`.
+  String _chooseProtocol(Request request) {
+    var protocols = request.headers['Sec-WebSocket-Protocol'];
+    if (protocols == null) return null;
+    for (var protocol in protocols.split(',')) {
+      protocol = protocol.trim();
+      if (_protocols.contains(protocol)) return protocol;
+    }
+    return null;
+  }
+
+  /// Returns a 404 Not Found response.
+  Response _notFound() => _htmlResponse(404, "404 Not Found",
+      "Only WebSocket connections are supported.");
+
+  /// Returns a 400 Bad Request response.
+  ///
+  /// [message] will be HTML-escaped before being included in the response body.
+  Response _badRequest(String message) => _htmlResponse(400, "400 Bad Request",
+      "Invalid WebSocket upgrade request: $message");
+
+  /// Returns a 403 Forbidden response.
+  ///
+  /// [message] will be HTML-escaped before being included in the response body.
+  Response _forbidden(String message) => _htmlResponse(403, "403 Forbidden",
+      "WebSocket upgrade refused: $message");
+
+  /// Creates an HTTP response with the given [statusCode] and an HTML body with
+  /// [title] and [message].
+  ///
+  /// [title] and [message] will be automatically HTML-escaped.
+  Response _htmlResponse(int statusCode, String title, String message) {
+    title = HTML_ESCAPE.convert(title);
+    message = HTML_ESCAPE.convert(message);
+    return new Response(statusCode, body: """
+      <!doctype html>
+      <html>
+        <head><title>$title</title></head>
+        <body>
+          <h1>$title</h1>
+          <p>$message</p>
+        </body>
+      </html>
+    """, headers: {'content-type': 'text/html'});
+  }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
new file mode 100644
index 0000000..7cecc42
--- /dev/null
+++ b/pubspec.yaml
@@ -0,0 +1,14 @@
+name: shelf_web_socket
+version: 0.0.1
+author: "Dart Team <misc@dartlang.org>"
+homepage: http://www.dartlang.org
+description: >
+  A WebSocket handler for Shelf.
+dependencies:
+  http_parser: ">=0.0.2 <0.1.0"
+  shelf: ">=0.5.2 <0.6.0"
+dev_dependencies:
+  http: ">=0.10.0 <0.12.0"
+  unittest: ">=0.10.0 <0.12.0"
+environment:
+  sdk: ">=1.4.0-dev.5.0 <2.0.0"
diff --git a/test/web_socket_test.dart b/test/web_socket_test.dart
new file mode 100644
index 0000000..f9ae2dc
--- /dev/null
+++ b/test/web_socket_test.dart
@@ -0,0 +1,167 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library shelf_web_socket.web_socket_test;
+
+import 'dart:io';
+
+import 'package:http/http.dart' as http;
+import 'package:shelf/shelf_io.dart' as shelf_io;
+import 'package:shelf_web_socket/shelf_web_socket.dart';
+import 'package:unittest/unittest.dart';
+
+Map<String, String> get _handshakeHeaders => {
+  "Upgrade": "websocket",
+  "Connection": "Upgrade",
+  "Sec-WebSocket-Key": "x3JJHMbDL1EzLkh9GBhXDw==",
+  "Sec-WebSocket-Version": "13"
+};
+
+void main() {
+  test("can communicate with a dart:io WebSocket client", () {
+    return shelf_io.serve(webSocketHandler((webSocket) {
+      webSocket.add("hello!");
+      webSocket.first.then((request) {
+        expect(request, equals("ping"));
+        webSocket.add("pong");
+        webSocket.close();
+      });
+    }), "localhost", 0).then((server) {
+      return WebSocket.connect('ws://localhost:${server.port}')
+          .then((webSocket) {
+        var n = 0;
+        return webSocket.listen((message) {
+          if (n == 0) {
+            expect(message, equals("hello!"));
+            webSocket.add("ping");
+          } else if (n == 1) {
+            expect(message, equals("pong"));
+            webSocket.close();
+            server.close();
+          } else {
+            fail("Only expected two messages.");
+          }
+          n++;
+        }).asFuture();
+      }).whenComplete(server.close);
+    });
+  });
+
+  test("negotiates the sub-protocol", () {
+    return shelf_io.serve(webSocketHandler((webSocket, protocol) {
+      expect(protocol, equals("two"));
+      webSocket.close();
+    }, protocols: ["three", "two", "x"]), "localhost", 0).then((server) {
+      return WebSocket.connect('ws://localhost:${server.port}',
+          protocols: ["one", "two", "three"]).then((webSocket) {
+        expect(webSocket.protocol, equals("two"));
+        return webSocket.close();
+      }).whenComplete(server.close);
+    });
+  });
+
+  group("with a set of allowed origins", () {
+    var server;
+    var url;
+    setUp(() {
+      return shelf_io.serve(webSocketHandler((webSocket) {
+        webSocket.close();
+      }, allowedOrigins: ["pub.dartlang.org", "GoOgLe.CoM"]), "localhost", 0)
+          .then((server_) {
+        server = server_;
+        url = 'http://localhost:${server.port}/';
+      });
+    });
+
+    tearDown(() => server.close());
+
+    test("allows access with an allowed origin", () {
+      var headers = _handshakeHeaders;
+      headers['Origin'] = 'pub.dartlang.org';
+      expect(http.get(url, headers: headers), hasStatus(101));
+    });
+
+    test("forbids access with a non-allowed origin", () {
+      var headers = _handshakeHeaders;
+      headers['Origin'] = 'dartlang.org';
+      expect(http.get(url, headers: headers), hasStatus(403));
+    });
+
+    test("allows access with no origin", () {
+      expect(http.get(url, headers: _handshakeHeaders), hasStatus(101));
+    });
+
+    test("ignores the case of the client origin", () {
+      var headers = _handshakeHeaders;
+      headers['Origin'] = 'PuB.DaRtLaNg.OrG';
+      expect(http.get(url, headers: headers), hasStatus(101));
+    });
+
+    test("ignores the case of the server origin", () {
+      var headers = _handshakeHeaders;
+      headers['Origin'] = 'google.com';
+      expect(http.get(url, headers: headers), hasStatus(101));
+    });
+  });
+
+  group("HTTP errors", () {
+    var server;
+    var url;
+    setUp(() {
+      return shelf_io.serve(webSocketHandler((_) {
+        fail("should not create a WebSocket");
+      }), "localhost", 0).then((server_) {
+        server = server_;
+        url = 'http://localhost:${server.port}/';
+      });
+    });
+
+    tearDown(() => server.close());
+
+    test("404s for non-GET requests", () {
+      expect(http.delete(url, headers: _handshakeHeaders), hasStatus(404));
+    });
+
+    test("404s for non-Upgrade requests", () {
+      var headers = _handshakeHeaders;
+      headers.remove('Connection');
+      expect(http.get(url, headers: headers), hasStatus(404));
+    });
+
+    test("404s for non-websocket upgrade requests", () {
+      var headers = _handshakeHeaders;
+      headers['Upgrade'] = 'fblthp';
+      expect(http.get(url, headers: headers), hasStatus(404));
+    });
+
+    test("400s for a missing Sec-WebSocket-Version", () {
+      var headers = _handshakeHeaders;
+      headers.remove('Sec-WebSocket-Version');
+      expect(http.get(url, headers: headers), hasStatus(400));
+    });
+
+    test("404s for an unknown Sec-WebSocket-Version", () {
+      var headers = _handshakeHeaders;
+      headers['Sec-WebSocket-Version'] = '15';
+      expect(http.get(url, headers: headers), hasStatus(404));
+    });
+
+    test("400s for a missing Sec-WebSocket-Key", () {
+      var headers = _handshakeHeaders;
+      headers.remove('Sec-WebSocket-Key');
+      expect(http.get(url, headers: headers), hasStatus(400));
+    });
+  });
+
+  test("throws an error if a unary function is provided with protocols", () {
+    expect(() => webSocketHandler((_) => null, protocols: ['foo']),
+        throwsArgumentError);
+  });
+}
+
+Matcher hasStatus(int status) => completion(predicate((response) {
+  expect(response, new isInstanceOf<http.Response>());
+  expect(response.statusCode, equals(status));
+  return true;
+}));