diff --git a/docs/sanic/blueprints.md b/docs/sanic/blueprints.md
index cfeb58c7..d1338023 100644
--- a/docs/sanic/blueprints.md
+++ b/docs/sanic/blueprints.md
@@ -55,6 +55,11 @@ will look like:
Blueprints have much the same functionality as an application instance.
+### WebSocket routes
+
+WebSocket handlers can be registered on a blueprint using the `@bp.route`
+decorator or `bp.add_websocket_route` method.
+
### Middleware
Using blueprints allows you to also register middleware globally.
diff --git a/docs/sanic/routing.md b/docs/sanic/routing.md
index 452f57ab..0434af94 100644
--- a/docs/sanic/routing.md
+++ b/docs/sanic/routing.md
@@ -181,3 +181,37 @@ url = app.url_for('post_handler', post_id=5, arg_one=['one', 'two'], arg_two=2,
# http://another_server:8888/posts/5?arg_one=one&arg_one=two&arg_two=2#anchor
```
- All valid parameters must be passed to `url_for` to build a URL. If a parameter is not supplied, or if a parameter does not match the specified type, a `URLBuildError` will be thrown.
+
+## WebSocket routes
+
+Routes for the WebSocket protocol can be defined with the `@app.websocket`
+decorator:
+
+```python
+@app.websocket('/feed')
+async def feed(request, ws):
+ while True:
+ data = 'hello!'
+ print('Sending: ' + data)
+ await ws.send(data)
+ data = await ws.recv()
+ print('Received: ' + data)
+```
+
+Alternatively, the `app.add_websocket_route` method can be used instead of the
+decorator:
+
+```python
+async def feed(request, ws):
+ pass
+
+app.add_websocket_route(my_websocket_handler, '/feed')
+```
+
+Handlers for a WebSocket route are passed the request as first argument, and a
+WebSocket protocol object as second argument. The protocol object has `send`
+and `recv` methods to send and receive data respectively.
+
+WebSocket support requires the [websockets](https://github.com/aaugustin/websockets)
+package by Aymeric Augustin.
+
diff --git a/examples/websocket.html b/examples/websocket.html
new file mode 100644
index 00000000..a3a98f35
--- /dev/null
+++ b/examples/websocket.html
@@ -0,0 +1,29 @@
+
+
+
+ WebSocket demo
+
+
+
+
+
diff --git a/examples/websocket.py b/examples/websocket.py
new file mode 100644
index 00000000..16cc4015
--- /dev/null
+++ b/examples/websocket.py
@@ -0,0 +1,23 @@
+from sanic import Sanic
+from sanic.response import file
+
+app = Sanic(__name__)
+
+
+@app.route('/')
+async def index(request):
+ return await file('websocket.html')
+
+
+@app.websocket('/feed')
+async def feed(request, ws):
+ while True:
+ data = 'hello!'
+ print('Sending: ' + data)
+ await ws.send(data)
+ data = await ws.recv()
+ print('Received: ' + data)
+
+
+if __name__ == '__main__':
+ app.run()
diff --git a/sanic/app.py b/sanic/app.py
index 63700e9d..bb52fdf8 100644
--- a/sanic/app.py
+++ b/sanic/app.py
@@ -19,6 +19,7 @@ from sanic.server import serve, serve_multiple, HttpProtocol
from sanic.static import register as static_register
from sanic.testing import TestClient
from sanic.views import CompositionView
+from sanic.websocket import WebSocketProtocol, ConnectionClosed
class Sanic:
@@ -51,6 +52,7 @@ class Sanic:
self.sock = None
self.listeners = defaultdict(list)
self.is_running = False
+ self.websocket_enabled = False
# Register alternative method names
self.go_fast = self.run
@@ -168,6 +170,50 @@ class Sanic:
self.route(uri=uri, methods=methods, host=host)(handler)
return handler
+ # Decorator
+ def websocket(self, uri, host=None):
+ """Decorate a function to be registered as a websocket route
+ :param uri: path of the URL
+ :param host:
+ :return: decorated function
+ """
+ self.websocket_enabled = True
+
+ # Fix case where the user did not prefix the URL with a /
+ # and will probably get confused as to why it's not working
+ if not uri.startswith('/'):
+ uri = '/' + uri
+
+ def response(handler):
+ async def websocket_handler(request, *args, **kwargs):
+ request.app = self
+ protocol = request.transport.get_protocol()
+ ws = await protocol.websocket_handshake(request)
+ try:
+ # invoke the application handler
+ await handler(request, ws, *args, **kwargs)
+ except ConnectionClosed:
+ pass
+ await ws.close()
+
+ self.router.add(uri=uri, handler=websocket_handler,
+ methods=frozenset({'GET'}), host=host)
+ return handler
+
+ return response
+
+ def add_websocket_route(self, handler, uri, host=None):
+ """A helper method to register a function as a websocket route."""
+ return self.websocket(uri, host=host)(handler)
+
+ def enable_websocket(self, enable=True):
+ """Enable or disable the support for websocket.
+
+ Websocket is enabled automatically if websocket routes are
+ added to the application.
+ """
+ self.websocket_enabled = enable
+
def remove_route(self, uri, clean_cache=True, host=None):
self.router.remove(uri, clean_cache, host)
@@ -437,7 +483,7 @@ class Sanic:
def run(self, host="127.0.0.1", port=8000, debug=False, before_start=None,
after_start=None, before_stop=None, after_stop=None, ssl=None,
- sock=None, workers=1, loop=None, protocol=HttpProtocol,
+ sock=None, workers=1, loop=None, protocol=None,
backlog=100, stop_event=None, register_sys_signals=True):
"""Run the HTTP Server and listen until keyboard interrupt or term
signal. On termination, drain connections before closing.
@@ -464,6 +510,9 @@ class Sanic:
:param protocol: Subclass of asyncio protocol class
:return: Nothing
"""
+ if protocol is None:
+ protocol = (WebSocketProtocol if self.websocket_enabled
+ else HttpProtocol)
server_settings = self._helper(
host=host, port=port, debug=debug, before_start=before_start,
after_start=after_start, before_stop=before_stop,
@@ -491,13 +540,16 @@ class Sanic:
async def create_server(self, host="127.0.0.1", port=8000, debug=False,
before_start=None, after_start=None,
before_stop=None, after_stop=None, ssl=None,
- sock=None, loop=None, protocol=HttpProtocol,
+ sock=None, loop=None, protocol=None,
backlog=100, stop_event=None):
"""Asynchronous version of `run`.
NOTE: This does not support multiprocessing and is not the preferred
way to run a Sanic application.
"""
+ if protocol is None:
+ protocol = (WebSocketProtocol if self.websocket_enabled
+ else HttpProtocol)
server_settings = self._helper(
host=host, port=port, debug=debug, before_start=before_start,
after_start=after_start, before_stop=before_stop,
diff --git a/sanic/blueprints.py b/sanic/blueprints.py
index 98b924b6..e17d4b81 100644
--- a/sanic/blueprints.py
+++ b/sanic/blueprints.py
@@ -23,6 +23,7 @@ class Blueprint:
self.host = host
self.routes = []
+ self.websocket_routes = []
self.exceptions = []
self.listeners = defaultdict(list)
self.middlewares = []
@@ -46,6 +47,17 @@ class Blueprint:
host=future.host or self.host
)(future.handler)
+ for future in self.websocket_routes:
+ # attach the blueprint name to the handler so that it can be
+ # prefixed properly in the router
+ future.handler.__blueprintname__ = self.name
+ # Prepend the blueprint URI prefix if available
+ uri = url_prefix + future.uri if url_prefix else future.uri
+ app.websocket(
+ uri=uri,
+ host=future.host or self.host
+ )(future.handler)
+
# Middleware
for future in self.middlewares:
if future.args or future.kwargs:
@@ -106,6 +118,28 @@ class Blueprint:
self.route(uri=uri, methods=methods, host=host)(handler)
return handler
+ def websocket(self, uri, host=None):
+ """Create a blueprint websocket route from a decorated function.
+
+ :param uri: endpoint at which the route will be accessible.
+ """
+ def decorator(handler):
+ route = FutureRoute(handler, uri, [], host)
+ self.websocket_routes.append(route)
+ return handler
+ return decorator
+
+ def add_websocket_route(self, handler, uri, host=None):
+ """Create a blueprint websocket route from a function.
+
+ :param handler: function for handling uri requests. Accepts function,
+ or class instance with a view_class method.
+ :param uri: endpoint at which the route will be accessible.
+ :return: function or class instance
+ """
+ self.websocket(uri=uri, host=host)(handler)
+ return handler
+
def listener(self, event):
"""Create a listener from a decorated function.
diff --git a/sanic/websocket.py b/sanic/websocket.py
new file mode 100644
index 00000000..a712eda8
--- /dev/null
+++ b/sanic/websocket.py
@@ -0,0 +1,67 @@
+from sanic.exceptions import InvalidUsage
+from sanic.server import HttpProtocol
+from httptools import HttpParserUpgrade
+from websockets import handshake, WebSocketCommonProtocol, InvalidHandshake
+from websockets import ConnectionClosed # noqa
+
+
+class WebSocketProtocol(HttpProtocol):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.websocket = None
+
+ def connection_timeout(self):
+ # timeouts make no sense for websocket routes
+ if self.websocket is None:
+ super().connection_timeout()
+
+ def connection_lost(self, exc):
+ if self.websocket is not None:
+ self.websocket.connection_lost(exc)
+ super().connection_lost(exc)
+
+ def data_received(self, data):
+ if self.websocket is not None:
+ # pass the data to the websocket protocol
+ self.websocket.data_received(data)
+ else:
+ try:
+ super().data_received(data)
+ except HttpParserUpgrade:
+ # this is okay, it just indicates we've got an upgrade request
+ pass
+
+ def write_response(self, response):
+ if self.websocket is not None:
+ # websocket requests do not write a response
+ self.transport.close()
+ else:
+ super().write_response(response)
+
+ async def websocket_handshake(self, request):
+ # let the websockets package do the handshake with the client
+ headers = []
+
+ def get_header(k):
+ return request.headers.get(k, '')
+
+ def set_header(k, v):
+ headers.append((k, v))
+
+ try:
+ key = handshake.check_request(get_header)
+ handshake.build_response(set_header, key)
+ except InvalidHandshake:
+ raise InvalidUsage('Invalid websocket request')
+
+ # write the 101 response back to the client
+ rv = b'HTTP/1.1 101 Switching Protocols\r\n'
+ for k, v in headers:
+ rv += k.encode('utf-8') + b': ' + v.encode('utf-8') + b'\r\n'
+ rv += b'\r\n'
+ request.transport.write(rv)
+
+ # hook up the websocket protocol
+ self.websocket = WebSocketCommonProtocol()
+ self.websocket.connection_made(request.transport)
+ return self.websocket
diff --git a/setup.py b/setup.py
index c73f3848..52e4a56e 100644
--- a/setup.py
+++ b/setup.py
@@ -19,6 +19,7 @@ install_requires = [
'httptools>=0.0.9',
'ujson>=1.35',
'aiofiles>=0.3.0',
+ 'websockets>=3.2',
]
if os.name != 'nt':
diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py
index 69d52737..fed4a03a 100644
--- a/tests/test_blueprints.py
+++ b/tests/test_blueprints.py
@@ -1,3 +1,4 @@
+import asyncio
import inspect
from sanic import Sanic
@@ -236,6 +237,7 @@ def test_bp_static():
def test_bp_shorthand():
app = Sanic('test_shorhand_routes')
blueprint = Blueprint('test_shorhand_routes')
+ ev = asyncio.Event()
@blueprint.get('/get')
def handler(request):
@@ -265,6 +267,10 @@ def test_bp_shorthand():
def handler(request):
return text('OK')
+ @blueprint.websocket('/ws')
+ async def handler(request, ws):
+ ev.set()
+
app.blueprint(blueprint)
request, response = app.test_client.get('/get')
@@ -308,3 +314,11 @@ def test_bp_shorthand():
request, response = app.test_client.get('/delete')
assert response.status == 405
+
+ request, response = app.test_client.get('/ws', headers={
+ 'Upgrade': 'websocket',
+ 'Connection': 'upgrade',
+ 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
+ 'Sec-WebSocket-Version': '13'})
+ assert response.status == 101
+ assert ev.is_set()
diff --git a/tests/test_routes.py b/tests/test_routes.py
index ca2173f0..afefe4a7 100644
--- a/tests/test_routes.py
+++ b/tests/test_routes.py
@@ -1,3 +1,4 @@
+import asyncio
import pytest
from sanic import Sanic
@@ -234,6 +235,23 @@ def test_dynamic_route_unhashable():
assert response.status == 404
+def test_websocket_route():
+ app = Sanic('test_websocket_route')
+ ev = asyncio.Event()
+
+ @app.websocket('/ws')
+ async def handler(request, ws):
+ ev.set()
+
+ request, response = app.test_client.get('/ws', headers={
+ 'Upgrade': 'websocket',
+ 'Connection': 'upgrade',
+ 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
+ 'Sec-WebSocket-Version': '13'})
+ assert response.status == 101
+ assert ev.is_set()
+
+
def test_route_duplicate():
app = Sanic('test_route_duplicate')
diff --git a/tox.ini b/tox.ini
index 05276a43..33e4298f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -12,6 +12,7 @@ python =
deps =
aiofiles
aiohttp
+ websockets
pytest
beautifulsoup4
coverage