Skip to content

Commit

Permalink
Add a middleware for promoting a connection to WebSocket (fix #205)
Browse files Browse the repository at this point in the history
Call 'next' if the client does not want to establish a handshake.

Add some documentation to cover the WebSocket middleware.
  • Loading branch information
arteymix committed Jan 15, 2018
1 parent aebd22b commit 6dfaddd
Show file tree
Hide file tree
Showing 11 changed files with 1,125 additions and 1 deletion.
2 changes: 2 additions & 0 deletions docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ Recent dependencies will enable more advanced features:
+-------------+---------+------------------------------------------------------+
| libsoup-2.4 | >=2.48 | new server API |
+-------------+---------+------------------------------------------------------+
| libsoup-2.4 | >=2.50 | support for WebSocket |
+-------------+---------+------------------------------------------------------+

You can also install additional dependencies to build the examples, you will
have to specify the ``-D enable_examples=true`` flag during the configure step.
Expand Down
1 change: 1 addition & 0 deletions docs/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ if sphinx.found()
'middlewares/static.rst',
'middlewares/status.rst',
'middlewares/subdomain.rst',
'middlewares/websocket.rst',
'recipes/bump.rst',
'recipes/caching.rst',
'recipes/configuration.rst',
Expand Down
1 change: 1 addition & 0 deletions docs/middlewares/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ from authentication to the delivery of a static resource.
static
status
subdomain
websocket

The typical way of declaring them involve closures. It is parametrized and
returned to perform a specific task:
Expand Down
33 changes: 33 additions & 0 deletions docs/middlewares/websocket.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
WebSocket
=========

.. versionadded:: 0.4

Valum support WebSocket via :valadoc:`libsoup-2.4/Soup.WebsocketConnection`
implementation if libsoup-2.4 (>=2.50) is installed.

.. note::

Not all server protocols support WebSocket. It is at least guaranteed to
work with the :doc:`../vsgi/server/http` server and for other, it should only a matter of
implementation.

The ``websocket`` middleware can be used in the context of a ``GET`` method. It
will perform the handshake and promote the underlying :doc:`../vsgi/connection`
to perform WebSocket message exchanges.

The first argument is a list of supported protocols, which can be left empty.
The second argument is a forward callback that will receive the WebSocket
connection.

::

app.get ("/", websocket ({}, (req, res, next, ctx, ws) => {
ws.send_text ();
return true;
}));

Since the middleware actually *steal* the connection, body streams are rendered
useless and futher communications should be done exclusively via the WebSocket
connection.

11 changes: 11 additions & 0 deletions examples/app/app.vala
Original file line number Diff line number Diff line change
Expand Up @@ -382,4 +382,15 @@ app.get ("/auth", authenticate (new BasicAuthentication (""), (a) => {

app.get ("/middleware", accept ("text/plain", forward_with<string> (new FooMiddleware ().fire)));

#if SOUP_2_50
app.get ("/websocket", websocket ({}, (req, res, next, ctx, ws) => {
ws.send_text ("test");
ws.message.connect ((type, msg) => {
ws.send_binary (msg.get_data ());
assert (res.status == 200);
});
return true;
}));
#endif

Server.@new ("http", handler: app).run ();
5 changes: 5 additions & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ if soup.version().version_compare('>=2.48')
add_project_arguments('--define=SOUP_2_48', language: 'vala')
endif

# provide 'Soup.WebsocketConnection'
if soup.version().version_compare('>=2.50')
add_project_arguments(['--define=SOUP_2_50'], language: 'vala')
endif

subdir('src')
subdir('tests')
subdir('docs')
Expand Down
3 changes: 2 additions & 1 deletion src/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ valum_sources = files(
'valum-server-sent-events.vala',
'valum-static.vala',
'valum-status.vala',
'valum-subdomain.vala')
'valum-subdomain.vala',
'valum-websocket.vala')
valum_lib = library('valum-' + api_version, valum_sources,
dependencies: [glib, gobject, gio, soup, vsgi],
vala_header: 'valum.h',
Expand Down
122 changes: 122 additions & 0 deletions src/valum-websocket.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* This file is part of Valum.
*
* Valum is free software: you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* Valum is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
* details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Valum. If not, see <http://www.gnu.org/licenses/>.
*/

using GLib;

namespace Valum {

#if SOUP_2_50
/**
* Perform a handshake and promote the {@link Request.connection} to
* communicate using the WebSocket protocol.
*
* If the client does not provide 'Connection: Upgrade' nor 'Upgrade: websocket',
* the control is passed to the next handler.
*
* @param protocols a list of supported protocols, or an empty array
* not to use any in particular
*/
[Version (since = "0.4")]
public HandlerCallback websocket (string[] protocols, ForwardCallback<Soup.WebsocketConnection> forward) {
return (req, res, next, ctx) => {
if (req.method != "GET") {
throw new ClientError.METHOD_NOT_ALLOWED ("The WebSocket protocol require a 'GET' method to be initiated.");
}

var connection = req.headers.get_one ("Connection");
if (connection == null || !Soup.str_case_equal (connection, "Upgrade")) {
return next ();
}

var upgrade = req.headers.get_one ("Upgrade");
if (upgrade == null || !Soup.str_case_equal (upgrade, "websocket")) {
return next ();
}

var ws_key = req.headers.get_one ("Sec-WebSocket-Key");
var ws_protocol = req.headers.get_list ("Sec-WebSocket-Protocol");
var ws_version = req.headers.get_one ("Sec-WebSocket-Version");

if (ws_key == null) {
throw new ClientError.BAD_REQUEST ("The 'Sec-WebSocket-Key' header is missing.");
}

if (ws_version == null) {
throw new ClientError.BAD_REQUEST ("The 'Sec-WebSocket-Version' header is missing.");
}

var ws_accept_sha1 = new Checksum (ChecksumType.SHA1);

ws_accept_sha1.update (ws_key.data, ws_key.length);
ws_accept_sha1.update ("258EAFA5-E914-47DA-95CA-C5AB0DC85B11".data, 36);

uint8 ws_accept_digest[20];
size_t ws_accept_digest_len = 20;
ws_accept_sha1.get_digest (ws_accept_digest, ref ws_accept_digest_len);

var ws_accept = Base64.encode (ws_accept_digest);

string? chosen_protocol = null;
if (protocols.length == 0) {
chosen_protocol = null;
} else if (ws_protocol == null) {
chosen_protocol = protocols[0];
} else {
// negotiate the protocol
foreach (var protocol in protocols) {
foreach (var client_protocol in Soup.header_parse_list (ws_protocol)) {
if (Soup.str_case_equal (protocol, client_protocol)) {
chosen_protocol = protocol;
break;
}
}
if (chosen_protocol != null) {
break;
}
}
}

if (res.head_written) {
throw new ServerError.INTERNAL_SERVER_ERROR ("The connection cannot be promoted to WebSocket: headers have already been written.");
}

res.status = Soup.Status.SWITCHING_PROTOCOLS;
res.headers.replace ("Connection", "Upgrade");
res.headers.replace ("Upgrade", "websocket");
res.headers.replace ("Sec-WebSocket-Accept", ws_accept);

// ensure that the full head has been sent and that no pending write
// will interfer with the WebSocket flux
res.write_head (null, null);
req.connection.output_stream.flush ();

IOStream? con = req.steal_connection ();
if (con == null) {
throw new ServerError.NOT_IMPLEMENTED ("...");
}

var ws_conn = new Soup.WebsocketConnection (con,
req.uri,
Soup.WebsocketConnectionType.SERVER,
req.headers.get_one ("Origin"),
chosen_protocol);

return forward (req, res, next, ctx, ws_conn);
};
}
#endif
}
122 changes: 122 additions & 0 deletions src/valum/valum-websocket.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* This file is part of Valum.
*
* Valum is free software: you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* Valum is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
* details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Valum. If not, see <http://www.gnu.org/licenses/>.
*/

using GLib;

namespace Valum {

#if SOUP_2_50
/**
* Perform a handshake and promote the {@link Request.connection} to
* communicate using the WebSocket protocol.
*
* If the client does not provide 'Connection: Upgrade' nor 'Upgrade: websocket',
* the control is passed to the next handler.
*
* @param protocols a list of supported protocols, or an empty array
* not to use any in particular
*/
[Version (since = "0.4")]
public HandlerCallback websocket (string[] protocols, ForwardCallback<Soup.WebsocketConnection> forward) {
return (req, res, next, ctx) => {
if (req.method != "GET") {
throw new ClientError.METHOD_NOT_ALLOWED ("The WebSocket protocol require a 'GET' method to be initiated.");
}

var connection = req.headers.get_one ("Connection");
if (connection == null || !Soup.str_case_equal (connection, "Upgrade")) {
return next ();
}

var upgrade = req.headers.get_one ("Upgrade");
if (upgrade == null || !Soup.str_case_equal (upgrade, "websocket")) {
return next ();
}

var ws_key = req.headers.get_one ("Sec-WebSocket-Key");
var ws_protocol = req.headers.get_list ("Sec-WebSocket-Protocol");
var ws_version = req.headers.get_one ("Sec-WebSocket-Version");

if (ws_key == null) {
throw new ClientError.BAD_REQUEST ("The 'Sec-WebSocket-Key' header is missing.");
}

if (ws_version == null) {
throw new ClientError.BAD_REQUEST ("The 'Sec-WebSocket-Version' header is missing.");
}

var ws_accept_sha1 = new Checksum (ChecksumType.SHA1);

ws_accept_sha1.update (ws_key.data, ws_key.length);
ws_accept_sha1.update ("258EAFA5-E914-47DA-95CA-C5AB0DC85B11".data, 36);

uint8 ws_accept_digest[20];
size_t ws_accept_digest_len = 20;
ws_accept_sha1.get_digest (ws_accept_digest, ref ws_accept_digest_len);

var ws_accept = Base64.encode (ws_accept_digest);

string? chosen_protocol = null;
if (protocols.length == 0) {
chosen_protocol = null;
} else if (ws_protocol == null) {
chosen_protocol = protocols[0];
} else {
// negotiate the protocol
foreach (var protocol in protocols) {
foreach (var client_protocol in Soup.header_parse_list (ws_protocol)) {
if (Soup.str_case_equal (protocol, client_protocol)) {
chosen_protocol = protocol;
break;
}
}
if (chosen_protocol != null) {
break;
}
}
}

if (res.head_written) {
throw new ServerError.INTERNAL_SERVER_ERROR ("The connection cannot be promoted to WebSocket: headers have already been written.");
}

res.status = Soup.Status.SWITCHING_PROTOCOLS;
res.headers.replace ("Connection", "Upgrade");
res.headers.replace ("Upgrade", "websocket");
res.headers.replace ("Sec-WebSocket-Accept", ws_accept);

// ensure that the full head has been sent and that no pending write
// will interfer with the WebSocket flux
res.write_head (null, null);
req.connection.output_stream.flush ();

IOStream? con = req.steal_connection ();
if (con == null) {
throw new ServerError.NOT_IMPLEMENTED ("...");
}

var ws_conn = new Soup.WebsocketConnection (con,
req.uri,
Soup.WebsocketConnectionType.SERVER,
req.headers.get_one ("Origin"),
chosen_protocol);

return forward (req, res, next, ctx, ws_conn);
};
}
#endif
}
Loading

0 comments on commit 6dfaddd

Please sign in to comment.