From 2d168dd3aa5aa25be07eb00d54d26605864c77bb Mon Sep 17 00:00:00 2001 From: Tobe O Date: Wed, 7 Nov 2018 23:11:10 -0500 Subject: [PATCH] Begin rolling in http2 --- example/http2/body_parsing.dart | 43 +++ example/http2/dev.key | 29 ++ example/http2/dev.pem | 57 ++++ example/http2/main.dart | 43 +++ example/http2/pretty_logging.dart | 9 + example/http2/public/app.js | 27 ++ example/http2/public/body_parsing.html | 21 ++ example/http2/public/index.html | 12 + example/http2/public/style.css | 20 ++ example/http2/server_push.dart | 59 ++++ lib/http2.dart | 3 + lib/src/core/core.dart | 1 + lib/src/core/driver.dart | 357 +++++++++++++++++++ lib/src/http/angel_http.dart | 399 ++++------------------ lib/src/http2/angel_http2.dart | 132 +++++++ lib/src/http2/http2_request_context.dart | 174 ++++++++++ lib/src/http2/http2_response_context.dart | 218 ++++++++++++ pubspec.yaml | 2 + test/http2/adapter_test.dart | 265 ++++++++++++++ test/http2/http2_client.dart | 101 ++++++ test/services_test.dart | 2 - 21 files changed, 1642 insertions(+), 332 deletions(-) create mode 100644 example/http2/body_parsing.dart create mode 100644 example/http2/dev.key create mode 100644 example/http2/dev.pem create mode 100644 example/http2/main.dart create mode 100644 example/http2/pretty_logging.dart create mode 100644 example/http2/public/app.js create mode 100644 example/http2/public/body_parsing.html create mode 100644 example/http2/public/index.html create mode 100644 example/http2/public/style.css create mode 100644 example/http2/server_push.dart create mode 100644 lib/http2.dart create mode 100644 lib/src/core/driver.dart create mode 100644 lib/src/http2/angel_http2.dart create mode 100644 lib/src/http2/http2_request_context.dart create mode 100644 lib/src/http2/http2_response_context.dart create mode 100644 test/http2/adapter_test.dart create mode 100644 test/http2/http2_client.dart diff --git a/example/http2/body_parsing.dart b/example/http2/body_parsing.dart new file mode 100644 index 00000000..b7d0fc77 --- /dev/null +++ b/example/http2/body_parsing.dart @@ -0,0 +1,43 @@ +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_framework/http2.dart'; +import 'package:file/local.dart'; +import 'package:logging/logging.dart'; +import 'pretty_logging.dart'; + +main() async { + var app = new Angel(); + app.logger = new Logger('angel')..onRecord.listen(prettyLog); + + var publicDir = new Directory('example/public'); + var indexHtml = + const LocalFileSystem().file(publicDir.uri.resolve('body_parsing.html')); + + app.get('/', (req, res) => res.streamFile(indexHtml)); + + app.post('/', (req, res) => req.parseBody()); + + var ctx = new SecurityContext() + ..useCertificateChain('dev.pem') + ..usePrivateKey('dev.key', password: 'dartdart'); + + try { + ctx.setAlpnProtocols(['h2'], true); + } catch (e, st) { + app.logger.severe( + 'Cannot set ALPN protocol on server to `h2`. The server will only serve HTTP/1.x.', + e, + st, + ); + } + + var http1 = new AngelHttp(app); + var http2 = new AngelHttp2(app, ctx); + + // HTTP/1.x requests will fallback to `AngelHttp` + http2.onHttp1.listen(http1.handleRequest); + + var server = await http2.startServer('127.0.0.1', 3000); + print('Listening at https://${server.address.address}:${server.port}'); +} diff --git a/example/http2/dev.key b/example/http2/dev.key new file mode 100644 index 00000000..5d49ae7e --- /dev/null +++ b/example/http2/dev.key @@ -0,0 +1,29 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIE5DAcBgoqhkiG9w0BDAEBMA4ECL7L6rj6uEHGAgIIAASCBMLbucyfqAkgCbhP +xNSHYllPMAv/dsIjtnsBwepCXPGkCBCuOAw/2FaCHjN9hBqL5V7fkrKeaemhm2YE +ycPtlHJYPDf3kEkyMjdZ9rIY6kePGfQizs2uJPcXj4YPyQ4HsfVXpOicKfQrouf5 +Mze9bGzeMN065q3iP4dYUMwHAyZYteXCsanQNHlqvsWli0W+H8St8fdsXefZhnv1 +qVatKWdNdWQ9t5MuljgNU2Vv56sHKEYXI0yLxk2QUMk8KlJfnmt8foYUsnPUXHmc +gIjLKwwVkpdololnEHSNu0cEOUPowjgJru+uMpn7vdNl7TPEQ9jbEgdNg4JwoYzU +0nao8WzjaSp7kzvZz0VFwKnk5AjstGvvuAWckADdq23QElbn/mF7AG1m/TBpYxzF +gTt37UdndS/AcvVznWVVrRP5iTSIawdIwvqI4s7rqsoE0GCcak+RhchgAz2gWKkS +oODUo0JL6pPVbJ3l4ebbaO6c99nDVc8dViPtc1EkStJEJ2O4kI4xgLSCr4Y9ahKn +oAaoSkX7Xxq3aQm+BzqSpLjdGL8atsqR/YVOIHYIl3gThvP0NfZGx1xHyvO5mCdZ +kHxSA7tKWxauZ3eQ2clbnzeRsl4El0WMHy/5K1ovene4v7sunmoXVtghBC8hK6eh +zMO9orex2PNQ/VQC7HCvtytunOVx1lkSBoNo7hR70igg6rW9H7UyoAoBOwMpT1xa +J6V62nqruTKOqFNfur7aHJGpHGtDb5/ickHeYCyPTvmGp67u4wChzKReeg02oECe +d1E5FKAcIa8s9TVOB6Z+HvTRNQZu2PsI6TJnjQRowvY9DAHiWTlJZBBY/pko3hxX +TsIeybpvRdEHpDWv86/iqtw1hv9CUxS/8ZTWUgBo+osShHW79FeDASr9FC4/Zn76 +ZDERTgV4YWlW/klVWcG2lFo7jix+OPXAB+ZQavLhlN1xdWBcIz1AUWjAM4hdPylW +HCX4PB9CQIPl2E7F+Y2p6nMcMWSJVBi5UIH7E9LfaBguXSzMmTk2Fw5p1aOQ6wfN +goVAMVwi8ppAVs741PfHdZ295xMmK/1LCxz5DeAdD/tsA/SYfT753GotioDuC7im +EyJ5JyvTr5I6RFFBuqt3NlUb3Hp16wP3B2x9DZiB6jxr0l341/NHgsyeBXkuIy9j +ON2mvpBPCJhS8kgWo3G0UyyKnx64tcgpGuSvZhGwPz843B6AbYyE6pMRfSWRMkMS +YZYa+VNKhR4ixdj07ocFZEWLVjCH7kxkE8JZXKt8jKYmkWd0lS1QVjgaKlO6lRa3 +q6SPJkhW6pvqobvcqVNXwi1XuzpZeEbuh0B7OTekFTTxx5g9XeDl56M8SVQ1KEhT +Q1t7H2Nba18WCB7cf+6PN0F0K0Jz1Kq7ZWaqEI/grX1m4RQuvNF5807sB/QKMO/Z +Gz3NXvHg5xTJRd/567lxPGkor0cE7qD1EZfmJ2HrBYXQ91bhgA7LToBuMZo6ZRXH +QfsanjbP4FPLMiGdQigLjj3A35L/f4sQOOVac/sRaFnm7pzcxsMvyVU/YtvGcjYE +xaOOVnamg661Wo0wksXoDjeSz/JIyyKO3Gwp1FSm2wGLjjy/Ehmqcqy8rvHuf07w +AUukhVtTNn4= +-----END ENCRYPTED PRIVATE KEY----- \ No newline at end of file diff --git a/example/http2/dev.pem b/example/http2/dev.pem new file mode 100644 index 00000000..01756b25 --- /dev/null +++ b/example/http2/dev.pem @@ -0,0 +1,57 @@ +-----BEGIN CERTIFICATE----- +MIIDKTCCAhGgAwIBAgIJAOWmjTS+OnTEMA0GCSqGSIb3DQEBCwUAMBcxFTATBgNV +BAMMDGludGVybWVkaWF0ZTAeFw0xNTA1MTgwOTAwNDBaFw0yMzA4MDQwOTAwNDBa +MBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBALlcwQJuzd+xH8QFgfJSn5tRlvhkldSX98cE7NiA602NBbnAVyUrkRXq +Ni75lgt0kwjYfA9z674m8WSVbgpLPintPCla9CYky1TH0keIs8Rz6cGWHryWEHiu +EDuljQynu2b3sAFuHu9nfWurbJwZnFakBKpdQ9m4EyOZCHC/jHYY7HacKSXg1Cki +we2ca0BWDrcqy8kLy0dZ5oC6IZG8O8drAK8f3f44CRYw59D3sOKBrKXaabpvyEcb +N7Wk2HDBVwHpUJo1reVwtbM8dhqQayYSD8oXnGpP3RQNu/e2rzlXRyq/BfcDY1JI +7TbC4t/7/N4EcPSpGsTcSOC9A7FpzvECAwEAAaN7MHkwCQYDVR0TBAIwADAsBglg +hkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0O +BBYEFCnwiEMMFZh7NhCr+qA8K0w4Q+AOMB8GA1UdIwQYMBaAFB0h1Evsaw2vfrmS +YuoCTmC4EE6ZMA0GCSqGSIb3DQEBCwUAA4IBAQAcFmHMaXRxyoNaeOowQ6iQWoZd +AUbvG7SHr7I6Pi2aqdqofsKWts7Ytm5WsS0M2nN+sW504houu0iCPeJJX8RQw2q4 +CCcNOs9IXk+2uMzlpocHpv+yYoUiD5DxgWh7eghQMLyMpf8FX3Gy4VazeuXznHOM +4gE4L417xkDzYOzqVTp0FTyAPUv6G2euhNCD6TMru9REcRhYul+K9kocjA5tt2KG +MH6y28LXbLyq4YJUxSUU9gY/xlnbbZS48KDqEcdYC9zjW9nQ0qS+XQuQuFIcwjJ5 +V4kAUYxDu6FoTpyQjgsrmBbZlKNxH7Nj4NDlcdJhp/zeSKHqWa5hSWjjKIxp +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDAjCCAeqgAwIBAgIJAOWmjTS+OnTDMA0GCSqGSIb3DQEBCwUAMBgxFjAUBgNV +BAMMDXJvb3RhdXRob3JpdHkwHhcNMTUwNTE4MDkwMDQwWhcNMjMwODA0MDkwMDQw +WjAXMRUwEwYDVQQDDAxpbnRlcm1lZGlhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDSrAO1CoPvUllgLOzDm5nG0skDF7vh1DUgAIDVGz0ecD0JFbQx +EF79pju/6MbtpTW2FYvRp11t/G7rGtX923ybOHY/1MNFQrdIvPlO1VV7IGKjoMwP +DNeb0fIGjHoE9QxaDxR8NX8xQbItpsw+TUtRfc9SLkR+jaYJfVRoM21BOncZbSHE +YKiZlEbpecB/+EtwVpgvl+8mPD5U07Fi4fp/lza3WXInXQPyiTVllIEJCt4PKmlu +MocNaJOW38bysL7i0PzDpVZtOxLHOTaW68yF3FckIHNCaA7k1ABEEEegjFMmIao7 +B9w7A0jvr4jZVvNmui5Djjn+oJxwEVVgyf8LAgMBAAGjUDBOMB0GA1UdDgQWBBQd +IdRL7GsNr365kmLqAk5guBBOmTAfBgNVHSMEGDAWgBRk81s9d0ZbiZhh44KckwPb +oTc0XzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBZQTK0plfdB5PC +cC5icut4EmrByJa1RbU7ayuEE70e7hla6KVmVjVdCBGltI4jBYwfhKbRItHiAJ/8 +x+XZKBG8DLPFuDb7lAa1ObhAYF7YThUFPQYaBhfzKcWrdmWDBFpvNv6E0Mm364dZ +e7Yxmbe5S4agkYPoxEzgEYmcUk9jbjdR6eTbs8laG169ljrECXfEU9RiAcqz5iSX +NLSewqB47hn3B9qgKcQn+PsgO2j7M+rfklhNgeGJeWmy7j6clSOuCsIjWHU0RLQ4 +0W3SB/rpEAJ7fgQbYUPTIUNALSOWi/o1tDX2mXPRjBoxqAv7I+vYk1lZPmSzkyRh +FKvRDxsW +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDAzCCAeugAwIBAgIJAJ0MomS4Ck+8MA0GCSqGSIb3DQEBCwUAMBgxFjAUBgNV +BAMMDXJvb3RhdXRob3JpdHkwHhcNMTUwNTE4MDkwMDQwWhcNMjMwODA0MDkwMDQw +WjAYMRYwFAYDVQQDDA1yb290YXV0aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAts1ijtBV92S2cOvpUMOSTp9c6A34nIGr0T5Nhz6XiqRVT+gv +dQgmkdKJQjbvR60y6jzltYFsI2MpGVXY8h/oAL81D/k7PDB2aREgyBfTPAhBHyGw +siR+2xYt5b/Zs99q5RdRqQNzNpLPJriIKvUsRyQWy1UiG2s7pRXQeA8qB0XtJdCj +kFIi+G2bDsaffspGeDOCqt7t+yqvRXfSES0c/l7DIHaiMbbp4//ZNML3RNgAjPz2 +hCezZ+wOYajOIyoSPK8IgICrhYFYxvgWxwbLDBEfC5B3jOQsySe10GoRAKZz1gBV +DmgReu81tYJmdgkc9zknnQtIFdA0ex+GvZlfWQIDAQABo1AwTjAdBgNVHQ4EFgQU +ZPNbPXdGW4mYYeOCnJMD26E3NF8wHwYDVR0jBBgwFoAUZPNbPXdGW4mYYeOCnJMD +26E3NF8wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEATzkZ97K777uZ +lQcduNX3ey4IbCiEzFA2zO5Blj+ilfIwNbZXNOgm/lqNvVGDYs6J1apJJe30vL3X +J+t2zsZWzzQzb9uIU37zYemt6m0fHrSrx/iy5lGNqt3HMfqEcOqSCOIK3PCTMz2/ +uyGe1iw33PVeWsm1JUybQ9IrU/huJjbgOHU4wab+8SJCM49ipArp68Fr6j4lcEaE +4rfRg1ZsvxiOyUB3qPn6wyL/JB8kOJ+QCBe498376eaem8AEFk0kQRh6hDaWtq/k +t6IIXQLjx+EBDVP/veK0UnVhKRP8YTOoV8ZiG1NcdlJmX/Uk7iAfevP7CkBfSN8W +r6AL284qtw== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/example/http2/main.dart b/example/http2/main.dart new file mode 100644 index 00000000..cb37957a --- /dev/null +++ b/example/http2/main.dart @@ -0,0 +1,43 @@ +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_framework/http2.dart'; +import 'package:logging/logging.dart'; +import 'pretty_logging.dart'; + +main() async { + var app = new Angel() + ..encoders.addAll({ + 'gzip': gzip.encoder, + 'deflate': zlib.encoder, + }); + app.logger = new Logger('angel')..onRecord.listen(prettyLog); + + app.get('/', (_, __) => 'Hello HTTP/2!!!'); + + app.fallback((req, res) => throw new AngelHttpException.notFound( + message: 'No file exists at ${req.uri.path}')); + + var ctx = new SecurityContext() + ..useCertificateChain('dev.pem') + ..usePrivateKey('dev.key', password: 'dartdart'); + + try { + ctx.setAlpnProtocols(['h2'], true); + } catch (e, st) { + app.logger.severe( + 'Cannot set ALPN protocol on server to `h2`. The server will only serve HTTP/1.x.', + e, + st, + ); + } + + var http1 = new AngelHttp(app); + var http2 = new AngelHttp2(app, ctx); + + // HTTP/1.x requests will fallback to `AngelHttp` + http2.onHttp1.listen(http1.handleRequest); + + var server = await http2.startServer('127.0.0.1', 3000); + print('Listening at https://${server.address.address}:${server.port}'); +} diff --git a/example/http2/pretty_logging.dart b/example/http2/pretty_logging.dart new file mode 100644 index 00000000..14f55e3f --- /dev/null +++ b/example/http2/pretty_logging.dart @@ -0,0 +1,9 @@ +import 'package:logging/logging.dart'; + +/// Prints the contents of a [LogRecord] with pretty colors. +void prettyLog(LogRecord record) { + print(record.toString()); + + if (record.error != null) print(record.error.toString()); + if (record.stackTrace != null) print(record.stackTrace.toString()); +} diff --git a/example/http2/public/app.js b/example/http2/public/app.js new file mode 100644 index 00000000..036c6dc3 --- /dev/null +++ b/example/http2/public/app.js @@ -0,0 +1,27 @@ +window.onload = function() { + var $app = document.getElementById('app'); + var $loading = document.getElementById('loading'); + $app.removeChild($loading); + var $button = document.createElement('button'); + var $h1 = document.createElement('h1'); + $app.appendChild($h1); + $app.appendChild($button); + + $h1.textContent = '~Angel HTTP/2 server push~'; + + $button.textContent = 'Change color'; + $button.onclick = function() { + var color = Math.floor(Math.random() * 0xffffff); + $h1.style.color = '#' + color.toString(16); + }; + + $button.onclick(); + + window.setInterval($button.onclick, 2000); + + var rotation = 0; + window.setInterval(function() { + rotation += .6; + $button.style.transform = 'rotate(' + rotation + 'deg)'; + }, 10); +}; \ No newline at end of file diff --git a/example/http2/public/body_parsing.html b/example/http2/public/body_parsing.html new file mode 100644 index 00000000..941d21c1 --- /dev/null +++ b/example/http2/public/body_parsing.html @@ -0,0 +1,21 @@ + + + + + Angel HTTP/2 + + + +
+ + + + + +
+ + \ No newline at end of file diff --git a/example/http2/public/index.html b/example/http2/public/index.html new file mode 100644 index 00000000..f0fe7160 --- /dev/null +++ b/example/http2/public/index.html @@ -0,0 +1,12 @@ + + + + + Angel HTTP/2 + + + +
Loading...
+ + + \ No newline at end of file diff --git a/example/http2/public/style.css b/example/http2/public/style.css new file mode 100644 index 00000000..e4348132 --- /dev/null +++ b/example/http2/public/style.css @@ -0,0 +1,20 @@ +button { + margin-top: 2em; +} + +html, body { + background-color: #000; +} + +#app { + text-align: center; +} + +#app h1 { + font-style: italic; + text-decoration: underline; +} + +#loading { + color: red; +} \ No newline at end of file diff --git a/example/http2/server_push.dart b/example/http2/server_push.dart new file mode 100644 index 00000000..814acf4f --- /dev/null +++ b/example/http2/server_push.dart @@ -0,0 +1,59 @@ +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_framework/http2.dart'; +import 'package:file/local.dart'; +import 'package:logging/logging.dart'; +import 'pretty_logging.dart'; + +main() async { + var app = new Angel(); + app.logger = new Logger('angel')..onRecord.listen(prettyLog); + + var publicDir = new Directory('example/public'); + var indexHtml = + const LocalFileSystem().file(publicDir.uri.resolve('index.html')); + var styleCss = + const LocalFileSystem().file(publicDir.uri.resolve('style.css')); + var appJs = const LocalFileSystem().file(publicDir.uri.resolve('app.js')); + + // Send files when requested + app + ..get('/style.css', (req, res) => res.streamFile(styleCss)) + ..get('/app.js', (req, res) => res.streamFile(appJs)); + + app.get('/', (req, res) async { + // Regardless of whether we pushed other resources, let's still send /index.html. + await res.streamFile(indexHtml); + + // If the client is HTTP/2 and supports server push, let's + // send down /style.css and /app.js as well, to improve initial load time. + if (res is Http2ResponseContext && res.canPush) { + await res.push('/style.css').streamFile(styleCss); + await res.push('/app.js').streamFile(appJs); + } + }); + + var ctx = new SecurityContext() + ..useCertificateChain('dev.pem') + ..usePrivateKey('dev.key', password: 'dartdart'); + + try { + ctx.setAlpnProtocols(['h2'], true); + } catch (e, st) { + app.logger.severe( + 'Cannot set ALPN protocol on server to `h2`. The server will only serve HTTP/1.x.', + e, + st, + ); + } + + var http1 = new AngelHttp(app); + var http2 = new AngelHttp2(app, ctx); + + // HTTP/1.x requests will fallback to `AngelHttp` + http2.onHttp1.listen(http1.handleRequest); + + var server = await http2.startServer('127.0.0.1', 3000); + print('Listening at https://${server.address.address}:${server.port}'); +} diff --git a/lib/http2.dart b/lib/http2.dart new file mode 100644 index 00000000..e21891f2 --- /dev/null +++ b/lib/http2.dart @@ -0,0 +1,3 @@ +export 'src/http2/angel_http2.dart'; +export 'src/http2/http2_request_context.dart'; +export 'src/http2/http2_response_context.dart'; diff --git a/lib/src/core/core.dart b/lib/src/core/core.dart index c1898302..3822463a 100644 --- a/lib/src/core/core.dart +++ b/lib/src/core/core.dart @@ -1,5 +1,6 @@ export 'anonymous_service.dart'; export 'controller.dart'; +export 'driver.dart'; export 'hooked_service.dart'; export 'map_service.dart'; export 'metadata.dart'; diff --git a/lib/src/core/driver.dart b/lib/src/core/driver.dart new file mode 100644 index 00000000..2f2acca2 --- /dev/null +++ b/lib/src/core/driver.dart @@ -0,0 +1,357 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' show stderr, Cookie; +import 'package:angel_http_exception/angel_http_exception.dart'; +import 'package:angel_route/angel_route.dart'; +import 'package:combinator/combinator.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:tuple/tuple.dart'; +import 'core.dart'; + +/// Base driver class for Angel implementations. +/// +/// Powers both AngelHttp and AngelHttp2. +abstract class Driver< + Request, + Response, + Server extends Stream, + RequestContextType extends RequestContext, + ResponseContextType extends ResponseContext> { + final Angel app; + final bool useZone; + bool _closed = false; + Server _server; + StreamSubscription _sub; + + /// The function used to bind this instance to a server.. + final Future Function(dynamic, int) serverGenerator; + + Driver(this.app, this.serverGenerator, {this.useZone: true}); + + /// The path at which this server is listening for requests. + Uri get uri; + + /// The native server running this instance. + Server get server => _server; + + /// Starts, and returns the server. + Future startServer([address, int port]) { + var host = address ?? '127.0.0.1'; + return serverGenerator(host, port ?? 0).then((server) { + _server = server; + return Future.wait(app.startupHooks.map(app.configure)).then((_) { + app.optimizeForProduction(); + _sub = server.listen((request) => + handleRawRequest(request, createResponseFromRawRequest(request))); + return _server; + }); + }); + } + + /// Shuts down the underlying server. + Future close() { + if (_closed) return new Future.value(_server); + _closed = true; + _sub?.cancel(); + return app.close().then((_) => + Future.wait(app.shutdownHooks.map(app.configure)).then((_) => _server)); + } + + Future createRequestContext( + Request request, Response response); + + Future createResponseContext( + Request request, Response response, + [RequestContextType correspondingRequest]); + + void setHeader(Response response, String key, String value); + + void setContentLength(Response response, int length); + + void setChunkedEncoding(Response response, bool value); + + void setStatusCode(Response response, int value); + + void addCookies(Response response, Iterable cookies); + + void writeStringToResponse(Response response, String value); + + void writeToResponse(Response response, List data); + + Uri getUriFromRequest(Request request); + + Future closeResponse(Response response); + + Response createResponseFromRawRequest(Request request); + + /// Handles a single request. + Future handleRawRequest(Request request, Response response) { + return createRequestContext(request, response).then((req) { + return createResponseContext(request, response, req).then((res) { + handle() { + var path = req.path; + if (path == '/') path = ''; + + Tuple3, ParseResult>> + resolveTuple() { + Router r = app.optimizedRouter; + var resolved = + r.resolveAbsolute(path, method: req.method, strip: false); + + return new Tuple3( + new MiddlewarePipeline(resolved).handlers, + resolved.fold>( + {}, (out, r) => out..addAll(r.allParams)), + resolved.isEmpty ? null : resolved.first.parseResult, + ); + } + + var cacheKey = req.method + path; + var tuple = app.isProduction + ? app.handlerCache.putIfAbsent(cacheKey, resolveTuple) + : resolveTuple(); + + req.params.addAll(tuple.item2); + + req.container.registerSingleton>>( + tuple.item3); + req.container.registerSingleton(tuple.item3); + + if (!app.isProduction && app.logger != null) { + req.container + .registerSingleton(new Stopwatch()..start()); + } + + var pipeline = tuple.item1; + + Future Function() runPipeline; + + for (var handler in pipeline) { + if (handler == null) break; + + if (runPipeline == null) + runPipeline = () => + Future.sync(() => app.executeHandler(handler, req, res)); + else { + var current = runPipeline; + runPipeline = () => current().then((result) => !res.isOpen + ? new Future.value(result) + : app.executeHandler(handler, req, res)); + } + } + + return runPipeline == null + ? sendResponse(request, response, req, res) + : runPipeline() + .then((_) => sendResponse(request, response, req, res)); + } + + if (useZone == false) { + Future f; + + try { + f = handle(); + } catch (e, st) { + f = Future.error(e, st); + } + + return f.catchError((e, StackTrace st) { + if (e is FormatException) + throw new AngelHttpException.badRequest(message: e.message) + ..stackTrace = st; + throw new AngelHttpException(e, + stackTrace: st, + statusCode: 500, + message: e?.toString() ?? '500 Internal Server Error'); + }, test: (e) => e is! AngelHttpException).catchError( + (ee, StackTrace st) { + var e = ee as AngelHttpException; + + if (app.logger != null) { + var error = e.error ?? e; + var trace = + new Trace.from(e.stackTrace ?? StackTrace.current).terse; + app.logger.severe(e.message ?? e.toString(), error, trace); + } + + return handleAngelHttpException( + e, e.stackTrace ?? st, req, res, request, response); + }); + } else { + var zoneSpec = new ZoneSpecification( + print: (self, parent, zone, line) { + if (app.logger != null) + app.logger.info(line); + else + parent.print(zone, line); + }, + handleUncaughtError: (self, parent, zone, error, stackTrace) { + var trace = + new Trace.from(stackTrace ?? StackTrace.current).terse; + + return new Future(() { + AngelHttpException e; + + if (error is FormatException) { + e = new AngelHttpException.badRequest(message: error.message); + } else if (error is AngelHttpException) { + e = error; + } else { + e = new AngelHttpException(error, + stackTrace: stackTrace, + message: + error?.toString() ?? '500 Internal Server Error'); + } + + if (app.logger != null) { + app.logger.severe(e.message ?? e.toString(), error, trace); + } + + return handleAngelHttpException( + e, trace, req, res, request, response); + }).catchError((e, StackTrace st) { + var trace = new Trace.from(st ?? StackTrace.current).terse; + var uri = getUriFromRequest(request); + closeResponse(response); + // Ideally, we won't be in a position where an absolutely fatal error occurs, + // but if so, we'll need to log it. + if (app.logger != null) { + app.logger.severe( + 'Fatal error occurred when processing $uri.', e, trace); + } else { + stderr + ..writeln('Fatal error occurred when processing ' + '$uri:') + ..writeln(e) + ..writeln(trace); + } + }); + }, + ); + + var zone = Zone.current.fork(specification: zoneSpec); + req.container.registerSingleton(zone); + req.container.registerSingleton(zoneSpec); + + // If a synchronous error is thrown, it's not caught by `zone.run`, + // so use a try/catch, and recover when need be. + + try { + return zone.run(handle); + } catch (e, st) { + zone.handleUncaughtError(e, st); + return Future.value(); + } + } + }); + }); + } + + /// Handles an [AngelHttpException]. + Future handleAngelHttpException( + AngelHttpException e, + StackTrace st, + RequestContext req, + ResponseContext res, + Request request, + Response response, + {bool ignoreFinalizers: false}) { + if (req == null || res == null) { + try { + app.logger?.severe(e, st); + setStatusCode(response, 500); + writeStringToResponse(response, '500 Internal Server Error'); + closeResponse(response); + } finally { + return null; + } + } + + Future handleError; + + if (!res.isOpen) + handleError = new Future.value(); + else { + res.statusCode = e.statusCode; + handleError = + new Future.sync(() => app.errorHandler(e, req, res)).then((result) { + return app.executeHandler(result, req, res).then((_) => res.close()); + }); + } + + return handleError.then((_) => sendResponse(request, response, req, res, + ignoreFinalizers: ignoreFinalizers == true)); + } + + /// Sends a response. + Future sendResponse(Request request, Response response, RequestContext req, + ResponseContext res, + {bool ignoreFinalizers: false}) { + void _cleanup(_) { + if (!app.isProduction && app.logger != null) { + var sw = req.container.make(); + app.logger.info( + "${res.statusCode} ${req.method} ${req.uri} (${sw?.elapsedMilliseconds ?? 'unknown'} ms)"); + } + } + + if (!res.isBuffered) return res.close().then(_cleanup); + + Future finalizers = ignoreFinalizers == true + ? new Future.value() + : app.responseFinalizers.fold( + new Future.value(), (out, f) => out.then((_) => f(req, res))); + + return finalizers.then((_) { + if (res.isOpen) res.close(); + + for (var key in res.headers.keys) { + setHeader(response, key, res.headers[key]); + } + + setContentLength(response, res.buffer.length); + setChunkedEncoding(response, res.chunked ?? true); + + List outputBuffer = res.buffer.toBytes(); + + if (res.encoders.isNotEmpty) { + var allowedEncodings = req.headers + .value('accept-encoding') + ?.split(',') + ?.map((s) => s.trim()) + ?.where((s) => s.isNotEmpty) + ?.map((str) { + // Ignore quality specifications in accept-encoding + // ex. gzip;q=0.8 + if (!str.contains(';')) return str; + return str.split(';')[0]; + }); + + if (allowedEncodings != null) { + for (var encodingName in allowedEncodings) { + Converter, List> encoder; + String key = encodingName; + + if (res.encoders.containsKey(encodingName)) + encoder = res.encoders[encodingName]; + else if (encodingName == '*') { + encoder = res.encoders[key = res.encoders.keys.first]; + } + + if (encoder != null) { + setHeader(response, 'content-encoding', key); + outputBuffer = res.encoders[key].convert(outputBuffer); + setContentLength(response, outputBuffer.length); + break; + } + } + } + } + + setStatusCode(response, res.statusCode); + addCookies(response, res.cookies); + writeToResponse(response, outputBuffer); + return closeResponse(response).then(_cleanup); + }); + } +} diff --git a/lib/src/http/angel_http.dart b/lib/src/http/angel_http.dart index e645d4c6..678356cd 100644 --- a/lib/src/http/angel_http.dart +++ b/lib/src/http/angel_http.dart @@ -2,19 +2,13 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io' show - stderr, + Cookie, HttpRequest, HttpResponse, HttpServer, Platform, SecurityContext; - -import 'package:angel_http_exception/angel_http_exception.dart'; -import 'package:angel_route/angel_route.dart'; -import 'package:combinator/combinator.dart'; -import 'package:stack_trace/stack_trace.dart'; -import 'package:tuple/tuple.dart'; - +import 'package:angel_framework/angel_framework.dart'; import '../core/core.dart'; import 'http_request_context.dart'; import 'http_response_context.dart'; @@ -22,41 +16,32 @@ import 'http_response_context.dart'; final RegExp _straySlashes = new RegExp(r'(^/+)|(/+$)'); /// Adapts `dart:io`'s [HttpServer] to serve Angel. -class AngelHttp { - final Angel app; - final bool useZone; - bool _closed = false; - HttpServer _server; - Future Function(dynamic, int) _serverGenerator = HttpServer.bind; - StreamSubscription _sub; +class AngelHttp extends Driver { + @override + Uri get uri => + new Uri(scheme: 'http', host: server.address.address, port: server.port); - AngelHttp(this.app, {this.useZone: false}); + AngelHttp._(Angel app, + Future Function(dynamic, int) serverGenerator, bool useZone) + : super(app, serverGenerator, useZone: useZone); - /// The path at which this server is listening for requests. - Uri get uri => new Uri( - scheme: 'http', host: _server.address.address, port: _server.port); - - /// The function used to bind this instance to an HTTP server. - Future Function(dynamic, int) get serverGenerator => - _serverGenerator; + factory AngelHttp(Angel app, {bool useZone: true}) { + return new AngelHttp._(app, HttpServer.bind, useZone); + } /// An instance mounted on a server started by the [serverGenerator]. factory AngelHttp.custom( Angel app, Future Function(dynamic, int) serverGenerator, {bool useZone: true}) { - return new AngelHttp(app, useZone: useZone) - .._serverGenerator = serverGenerator; + return new AngelHttp._(app, serverGenerator, useZone); } factory AngelHttp.fromSecurityContext(Angel app, SecurityContext context, {bool useZone: true}) { - var http = new AngelHttp(app, useZone: useZone); - - http._serverGenerator = (address, int port) { + return new AngelHttp._(app, (address, int port) { return HttpServer.bindSecure(address, port, context); - }; - - return http; + }, useZone); } /// Creates an HTTPS server. @@ -73,316 +58,70 @@ class AngelHttp { var serverContext = new SecurityContext(); serverContext.useCertificateChain(certificateChain, password: password); serverContext.usePrivateKey(serverKey, password: password); - return new AngelHttp.fromSecurityContext(app, serverContext, useZone: useZone); } - /// The native HttpServer running this instance. - HttpServer get httpServer => _server; + /// Use [server] instead. + @deprecated + HttpServer get httpServer => server; - /// Starts the server. - /// - /// Returns false on failure; otherwise, returns the HttpServer. - Future startServer([address, int port]) { - var host = address ?? '127.0.0.1'; - return _serverGenerator(host, port ?? 0).then((server) { - _server = server; - return Future.wait(app.startupHooks.map(app.configure)).then((_) { - app.optimizeForProduction(); - _sub = _server.listen(handleRequest); - return _server; - }); - }); - } + Future handleRequest(HttpRequest request) => + handleRawRequest(request, request.response); - /// Shuts down the underlying server. - Future close() { - if (_closed) return new Future.value(_server); - _closed = true; - _sub?.cancel(); - return app.close().then((_) => - Future.wait(app.shutdownHooks.map(app.configure)).then((_) => _server)); - } + @override + void addCookies(HttpResponse response, Iterable cookies) => + response.cookies.addAll(cookies); - /// Handles a single request. - Future handleRequest(HttpRequest request) { - return createRequestContext(request).then((req) { - return createResponseContext(request.response, req).then((res) { - handle() { - var path = req.path; - if (path == '/') path = ''; + @override + Future closeResponse(HttpResponse response) => response.close(); - Tuple3, ParseResult>> - resolveTuple() { - Router r = app.optimizedRouter; - var resolved = - r.resolveAbsolute(path, method: req.method, strip: false); - - return new Tuple3( - new MiddlewarePipeline(resolved).handlers, - resolved.fold>( - {}, (out, r) => out..addAll(r.allParams)), - resolved.isEmpty ? null : resolved.first.parseResult, - ); - } - - var cacheKey = req.method + path; - var tuple = app.isProduction - ? app.handlerCache.putIfAbsent(cacheKey, resolveTuple) - : resolveTuple(); - - req.params.addAll(tuple.item2); - - req.container.registerSingleton>>( - tuple.item3); - req.container.registerSingleton(tuple.item3); - - if (!app.isProduction && app.logger != null) { - req.container - .registerSingleton(new Stopwatch()..start()); - } - - var pipeline = tuple.item1; - - Future Function() runPipeline; - - for (var handler in pipeline) { - if (handler == null) break; - - if (runPipeline == null) - runPipeline = () => - Future.sync(() => app.executeHandler(handler, req, res)); - else { - var current = runPipeline; - runPipeline = () => current().then((result) => !res.isOpen - ? new Future.value(result) - : app.executeHandler(handler, req, res)); - } - } - - return runPipeline == null - ? sendResponse(request, req, res) - : runPipeline().then((_) => sendResponse(request, req, res)); - } - - if (useZone == false) { - Future f; - - try { - f = handle(); - } catch (e, st) { - f = Future.error(e, st); - } - - return f.catchError((e, StackTrace st) { - if (e is FormatException) - throw new AngelHttpException.badRequest(message: e.message) - ..stackTrace = st; - throw new AngelHttpException(e, - stackTrace: st, - statusCode: 500, - message: e?.toString() ?? '500 Internal Server Error'); - }, test: (e) => e is! AngelHttpException).catchError( - (ee, StackTrace st) { - var e = ee as AngelHttpException; - - if (app.logger != null) { - var error = e.error ?? e; - var trace = - new Trace.from(e.stackTrace ?? StackTrace.current).terse; - app.logger.severe(e.message ?? e.toString(), error, trace); - } - - return handleAngelHttpException( - e, e.stackTrace ?? st, req, res, request); - }); - } else { - var zoneSpec = new ZoneSpecification( - print: (self, parent, zone, line) { - if (app.logger != null) - app.logger.info(line); - else - parent.print(zone, line); - }, - handleUncaughtError: (self, parent, zone, error, stackTrace) { - var trace = - new Trace.from(stackTrace ?? StackTrace.current).terse; - - return new Future(() { - AngelHttpException e; - - if (error is FormatException) { - e = new AngelHttpException.badRequest(message: error.message); - } else if (error is AngelHttpException) { - e = error; - } else { - e = new AngelHttpException(error, - stackTrace: stackTrace, - message: - error?.toString() ?? '500 Internal Server Error'); - } - - if (app.logger != null) { - app.logger.severe(e.message ?? e.toString(), error, trace); - } - - return handleAngelHttpException(e, trace, req, res, request); - }).catchError((e, StackTrace st) { - var trace = new Trace.from(st ?? StackTrace.current).terse; - request.response.close(); - // Ideally, we won't be in a position where an absolutely fatal error occurs, - // but if so, we'll need to log it. - if (app.logger != null) { - app.logger.severe( - 'Fatal error occurred when processing ${request.uri}.', - e, - trace); - } else { - stderr - ..writeln('Fatal error occurred when processing ' - '${request.uri}:') - ..writeln(e) - ..writeln(trace); - } - }); - }, - ); - - var zone = Zone.current.fork(specification: zoneSpec); - req.container.registerSingleton(zone); - req.container.registerSingleton(zoneSpec); - - // If a synchronous error is thrown, it's not caught by `zone.run`, - // so use a try/catch, and recover when need be. - - try { - return zone.run(handle); - } catch (e, st) { - zone.handleUncaughtError(e, st); - return Future.value(); - } - } - }); - }); - } - - /// Handles an [AngelHttpException]. - Future handleAngelHttpException(AngelHttpException e, StackTrace st, - RequestContext req, ResponseContext res, HttpRequest request, - {bool ignoreFinalizers: false}) { - if (req == null || res == null) { - try { - app.logger?.severe(e, st); - request.response - ..statusCode = 500 - ..write('500 Internal Server Error') - ..close(); - } finally { - return null; - } - } - - Future handleError; - - if (!res.isOpen) - handleError = new Future.value(); - else { - res.statusCode = e.statusCode; - handleError = - new Future.sync(() => app.errorHandler(e, req, res)).then((result) { - return app.executeHandler(result, req, res).then((_) => res.close()); - }); - } - - return handleError.then((_) => sendResponse(request, req, res, - ignoreFinalizers: ignoreFinalizers == true)); - } - - /// Sends a response. - Future sendResponse( - HttpRequest request, RequestContext req, ResponseContext res, - {bool ignoreFinalizers: false}) { - void _cleanup(_) { - if (!app.isProduction && app.logger != null) { - var sw = req.container.make(); - app.logger.info( - "${res.statusCode} ${req.method} ${req.uri} (${sw?.elapsedMilliseconds ?? 'unknown'} ms)"); - } - } - - if (!res.isBuffered) return res.close().then(_cleanup); - - Future finalizers = ignoreFinalizers == true - ? new Future.value() - : app.responseFinalizers.fold( - new Future.value(), (out, f) => out.then((_) => f(req, res))); - - return finalizers.then((_) { - if (res.isOpen) res.close(); - - for (var key in res.headers.keys) { - request.response.headers.add(key, res.headers[key]); - } - - request.response.contentLength = res.buffer.length; - request.response.headers.chunkedTransferEncoding = res.chunked ?? true; - - List outputBuffer = res.buffer.toBytes(); - - if (res.encoders.isNotEmpty) { - var allowedEncodings = req.headers - .value('accept-encoding') - ?.split(',') - ?.map((s) => s.trim()) - ?.where((s) => s.isNotEmpty) - ?.map((str) { - // Ignore quality specifications in accept-encoding - // ex. gzip;q=0.8 - if (!str.contains(';')) return str; - return str.split(';')[0]; - }); - - if (allowedEncodings != null) { - for (var encodingName in allowedEncodings) { - Converter, List> encoder; - String key = encodingName; - - if (res.encoders.containsKey(encodingName)) - encoder = res.encoders[encodingName]; - else if (encodingName == '*') { - encoder = res.encoders[key = res.encoders.keys.first]; - } - - if (encoder != null) { - request.response.headers.set('content-encoding', key); - outputBuffer = res.encoders[key].convert(outputBuffer); - request.response.contentLength = outputBuffer.length; - break; - } - } - } - } - - request.response - ..statusCode = res.statusCode - ..cookies.addAll(res.cookies) - ..add(outputBuffer); - - return request.response.close().then(_cleanup); - }); - } - - Future createRequestContext(HttpRequest request) { + @override + Future createRequestContext( + HttpRequest request, HttpResponse response) { var path = request.uri.path.replaceAll(_straySlashes, ''); if (path.length == 0) path = '/'; return HttpRequestContext.from(request, app, path); } - Future createResponseContext(HttpResponse response, - [RequestContext correspondingRequest]) => - new Future.value(new HttpResponseContext( - response, app, correspondingRequest as HttpRequestContext) - ..serializer = (app.serializer ?? json.encode) - ..encoders.addAll(app.encoders ?? {})); + @override + Future createResponseContext( + HttpRequest request, HttpResponse response, + [HttpRequestContext correspondingRequest]) { + return new Future.value( + new HttpResponseContext(response, app, correspondingRequest) + ..serializer = (app.serializer ?? json.encode) + ..encoders.addAll(app.encoders ?? {})); + } + + @override + HttpResponse createResponseFromRawRequest(HttpRequest request) => + request.response; + + @override + Uri getUriFromRequest(HttpRequest request) => request.uri; + + @override + void setChunkedEncoding(HttpResponse response, bool value) => + response.headers.chunkedTransferEncoding = value; + + @override + void setContentLength(HttpResponse response, int length) => + response.headers.contentLength = length; + + @override + void setHeader(HttpResponse response, String key, String value) => + response.headers.set(key, value); + + @override + void setStatusCode(HttpResponse response, int value) => + response.statusCode = value; + + @override + void writeStringToResponse(HttpResponse response, String value) => + response.write(value); + + @override + void writeToResponse(HttpResponse response, List data) => + response.add(data); } diff --git a/lib/src/http2/angel_http2.dart b/lib/src/http2/angel_http2.dart new file mode 100644 index 00000000..e96c1f62 --- /dev/null +++ b/lib/src/http2/angel_http2.dart @@ -0,0 +1,132 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart' hide Header; +import 'package:combinator/combinator.dart'; +import 'package:http2/src/artificial_server_socket.dart'; +import 'package:http2/transport.dart'; +import 'package:mock_request/mock_request.dart'; +import 'http2_request_context.dart'; +import 'http2_response_context.dart'; +import 'package:pool/pool.dart'; +import 'package:uuid/uuid.dart'; +import 'package:tuple/tuple.dart'; + +class AngelHttp2 extends Driver { + final ServerSettings settings; + final StreamController _onHttp1 = new StreamController(); + final Map _sessions = {}; + final Uuid _uuid = new Uuid(); + ArtificialServerSocket _artificial; + HttpServer _httpServer; + StreamController _http1; + SecureServerSocket _socket; + StreamSubscription _sub; + + AngelHttp2._( + Angel app, + Future Function(dynamic, int) serverGenerator, + bool useZone, + this.settings) + : super(app, serverGenerator, useZone: useZone); + + factory AngelHttp2(Angel app, SecurityContext securityContext, + {bool useZone: true, ServerSettings settings}) { + return new AngelHttp2.custom(app, securityContext, SecureServerSocket.bind, + settings: settings); + } + + factory AngelHttp2.custom( + Angel app, + SecurityContext ctx, + Future serverGenerator( + address, int port, SecurityContext ctx), + {bool useZone: true, + ServerSettings settings}) { + return new AngelHttp2._(app, (address, port) { + var addr = address is InternetAddress + ? address + : new InternetAddress(address.toString()); + return SecureServerSocket.bind(addr, port, ctx) + .then((s) => ArtificialServerSocket(addr, port, s)); + }, useZone, settings); + } + + /// Fires when an HTTP/1.x request is received. + Stream get onHttp1 => _onHttp1.stream; + + @override + void addCookies(ServerTransportStream response, Iterable cookies) { + var headers = cookies + .map((cookie) => new Header.ascii('set-cookie', cookie.toString())); + response.sendHeaders(headers.toList()); + } + + @override + Future closeResponse(ServerTransportStream response) { + response.terminate(); + return new Future.value(); + } + + @override + Future createRequestContext( + Socket request, ServerTransportStream response) { + return Http2RequestContext.from(response, request, app, _sessions, _uuid); + } + + @override + Future createResponseContext( + Socket request, ServerTransportStream response, + [Http2RequestContext correspondingRequest]) async { + return new Http2ResponseContext(app, response, correspondingRequest) + ..encoders.addAll(app.encoders); + } + + @override + ServerTransportStream createResponseFromRawRequest(Socket request) { + var connection = + new ServerTransportConnection.viaSocket(request, settings: settings); + } + + @override + Uri getUriFromRequest(Socket request) { + // TODO: implement getUriFromRequest + } + + @override + void setChunkedEncoding(ServerTransportStream response, bool value) { + // Do nothing in HTTP/2 + } + + @override + void setContentLength(ServerTransportStream response, int length) { + setHeader(response, 'content-length', length.toString()); + } + + @override + void setHeader(ServerTransportStream response, String key, String value) { + response.sendHeaders([new Header.ascii(key, value)]); + } + + @override + void setStatusCode(ServerTransportStream response, int value) { + response.sendHeaders([new Header.ascii(':status', value.toString())]); + } + + @override + Uri get uri => Uri( + scheme: 'https', + host: server.address.address, + port: server.port != 443 ? server.port : null); + + @override + void writeStringToResponse(ServerTransportStream response, String value) { + writeToResponse(response, utf8.encode(value)); + } + + @override + void writeToResponse(ServerTransportStream response, List data) { + response.sendData(data); + } +} diff --git a/lib/src/http2/http2_request_context.dart b/lib/src/http2/http2_request_context.dart new file mode 100644 index 00000000..f207067b --- /dev/null +++ b/lib/src/http2/http2_request_context.dart @@ -0,0 +1,174 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:body_parser/body_parser.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:http2/transport.dart'; +import 'package:mock_request/mock_request.dart'; +import 'package:uuid/uuid.dart'; + +final RegExp _comma = new RegExp(r',\s*'); +final RegExp _straySlashes = new RegExp(r'(^/+)|(/+$)'); + +class Http2RequestContext extends RequestContext { + BytesBuilder _buf; + ContentType _contentType; + List _cookies; + HttpHeaders _headers; + String _method, _override, _path; + HttpSession _session; + Socket _socket; + ServerTransportStream _stream; + Uri _uri; + + static Future from( + ServerTransportStream stream, + Socket socket, + Angel app, + Map sessions, + Uuid uuid) async { + var req = new Http2RequestContext() + ..app = app + .._socket = socket + .._stream = stream; + + var buf = req._buf = new BytesBuilder(); + var headers = req._headers = new MockHttpHeaders(); + String scheme = 'https', + authority = '${socket.address.address}:${socket.port}', + path = ''; + var cookies = []; + + await for (var msg in stream.incomingMessages) { + if (msg is DataStreamMessage) { + buf.add(msg.bytes); + } else if (msg is HeadersStreamMessage) { + for (var header in msg.headers) { + var name = ascii.decode(header.name).toLowerCase(); + var value = ascii.decode(header.value); + + switch (name) { + case ':method': + req._method = value; + break; + case ':path': + path = value.replaceAll(_straySlashes, ''); + req._path = path; + if (path.isEmpty) req._path = '/'; + break; + case ':scheme': + scheme = value; + break; + case ':authority': + authority = value; + break; + case 'cookie': + var cookieStrings = value.split(';').map((s) => s.trim()); + + for (var cookieString in cookieStrings) { + try { + cookies.add(new Cookie.fromSetCookieValue(cookieString)); + } catch (_) { + // Ignore malformed cookies, and just don't add them to the container. + } + } + break; + default: + headers.add(ascii.decode(header.name), value.split(_comma)); + break; + } + } + } + + //if (msg.endStream) break; + } + + req + .._cookies = new List.unmodifiable(cookies) + .._uri = Uri.parse('$scheme://$authority').replace(path: path); + + // Apply session + var dartSessId = + cookies.firstWhere((c) => c.name == 'DARTSESSID', orElse: () => null); + + if (dartSessId == null) { + dartSessId = new Cookie('DARTSESSID', uuid.v4()); + } + + req._session = sessions.putIfAbsent( + dartSessId.value, + () => new MockHttpSession(id: dartSessId.value), + ); + + return req; + } + + @override + List get cookies => _cookies; + + /// The underlying HTTP/2 [ServerTransportStream]. + ServerTransportStream get stream => _stream; + + @override + bool get xhr { + return headers.value("X-Requested-With")?.trim()?.toLowerCase() == + 'xmlhttprequest'; + } + + @override + Uri get uri => _uri; + + @override + HttpSession get session { + return _session; + } + + @override + InternetAddress get remoteAddress => _socket.remoteAddress; + + @override + String get path { + return _path; + } + + @override + ContentType get contentType => + _contentType ??= (headers['content-type'] == null + ? null + : ContentType.parse(headers.value('content-type'))); + + @override + String get originalMethod { + return _method; + } + + @override + String get method { + return _override ?? _method; + } + + @override + HttpRequest get io => null; + + @override + String get hostname => _headers.value('host'); + + @override + HttpHeaders get headers => _headers; + + @override + Future close() { + return super.close(); + } + + @override + Future parseOnce() { + return parseBodyFromStream( + new Stream.fromIterable([_buf.takeBytes()]), + contentType == null ? null : new MediaType.parse(contentType.toString()), + uri, + storeOriginalBuffer: app.storeOriginalBuffer, + ); + } +} diff --git a/lib/src/http2/http2_response_context.dart b/lib/src/http2/http2_response_context.dart new file mode 100644 index 00000000..05cf07b5 --- /dev/null +++ b/lib/src/http2/http2_response_context.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart' hide Header; +import 'package:http2/transport.dart'; +import 'http2_request_context.dart'; + +class Http2ResponseContext extends ResponseContext { + final Angel app; + final ServerTransportStream stream; + final Http2RequestContext _req; + bool _useStream = false, _isClosed = false, _isPush = false; + Uri _targetUri; + + Http2ResponseContext(this.app, this.stream, this._req) { + _targetUri = _req.uri; + } + + final List _pushes = []; + + /// Returns `true` if an attempt to [push] a resource will succeed. + /// + /// See [ServerTransportStream].`push`. + bool get canPush => stream.canPush; + + /// Returns a [List] of all resources that have [push]ed to the client. + List get pushes => new List.unmodifiable(_pushes); + + @override + RequestContext get correspondingRequest => _req; + + Uri get targetUri => _targetUri; + + @override + HttpResponse get io => null; + + @override + bool get streaming => _useStream; + + @override + bool get isOpen => !_isClosed; + + /// Write headers, status, etc. to the underlying [stream]. + void finalize() { + if (_isPush) return; + + var headers =
[ + new Header.ascii(':status', statusCode.toString()), + ]; + + if (encoders.isNotEmpty && correspondingRequest != null) { + var allowedEncodings = + (correspondingRequest.headers['accept-encoding'] ?? []).map((str) { + // Ignore quality specifications in accept-encoding + // ex. gzip;q=0.8 + if (!str.contains(';')) return str; + return str.split(';')[0]; + }); + + for (var encodingName in allowedEncodings) { + String key = encodingName; + + if (encoders.containsKey(encodingName)) { + this.headers['content-encoding'] = key; + break; + } + } + } + + // Add all normal headers + for (var key in this.headers.keys) { + headers.add(new Header.ascii(key.toLowerCase(), this.headers[key])); + } + + // Persist session ID + cookies.add(new Cookie('DARTSESSID', _req.session.id)); + + // Send all cookies + for (var cookie in cookies) { + headers.add(new Header.ascii('set-cookie', cookie.toString())); + } + + stream.sendHeaders(headers); + } + + @override + void addError(Object error, [StackTrace stackTrace]) { + Zone.current.handleUncaughtError(error, stackTrace); + super.addError(error, stackTrace); + } + + @override + bool useStream() { + if (!_useStream) { + // If this is the first stream added to this response, + // then add headers, status code, etc. + finalize(); + + willCloseItself = _useStream = _isClosed = true; + releaseCorrespondingRequest(); + return true; + } + + return false; + } + + @override + void end() { + _isClosed = true; + super.end(); + } + + @override + Future addStream(Stream> stream) { + if (_isClosed && !_useStream) throw ResponseContext.closed(); + var firstStream = useStream(); + + Stream> output = stream; + + if ((firstStream || !headers.containsKey('content-encoding')) && + encoders.isNotEmpty && + correspondingRequest != null) { + var allowedEncodings = + (correspondingRequest.headers['accept-encoding'] ?? []).map((str) { + // Ignore quality specifications in accept-encoding + // ex. gzip;q=0.8 + if (!str.contains(';')) return str; + return str.split(';')[0]; + }); + + for (var encodingName in allowedEncodings) { + Converter, List> encoder; + String key = encodingName; + + if (encoders.containsKey(encodingName)) + encoder = encoders[encodingName]; + else if (encodingName == '*') { + encoder = encoders[key = encoders.keys.first]; + } + + if (encoder != null) { + /* + if (firstStream) { + this.stream.sendHeaders([ + new Header.ascii( + 'content-encoding', headers['content-encoding'] = key) + ]); + } + */ + + output = encoders[key].bind(output); + break; + } + } + } + + return output.forEach(this.stream.sendData); + } + + @override + void add(List data) { + if (_isClosed && !_useStream) + throw ResponseContext.closed(); + else if (_useStream) + //stream.sendData(data); + addStream(new Stream.fromIterable([data])); + else + buffer.add(data); + } + + @override + Future close() async { + if (_useStream) { + try { + await stream.outgoingMessages.close(); + } catch (_) { + // This only seems to occur on `MockHttpRequest`, but + // this try/catch prevents a crash. + } + } + + _isClosed = true; + await super.close(); + _useStream = false; + } + + /// Pushes a resource to the client. + Http2ResponseContext push(String path, + {Map headers: const {}, String method: 'GET'}) { + if (isOpen) + throw new StateError( + 'You can only push resources after the main response context is closed. You will need to use streaming methods, i.e. `addStream`.'); + + var targetUri = _req.uri.replace(path: path); + + var h =
[ + new Header.ascii(':authority', targetUri.authority), + new Header.ascii(':method', method), + new Header.ascii(':path', targetUri.path), + new Header.ascii(':scheme', targetUri.scheme), + ]; + + for (var key in headers.keys) { + h.add(new Header.ascii(key, headers[key])); + } + + var s = stream.push(h); + var r = new Http2ResponseContext(app, s, _req) + .._isPush = true + .._targetUri = targetUri; + _pushes.add(r); + return r; + } + + void internalReopen() { + _isClosed = false; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 93a2d054..a9b080fb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: dart2_constant: ^1.0.0 file: ^5.0.0 http_parser: ^3.0.0 + http2: ">=0.1.7 <2.0.0" logging: ">=0.11.3 <1.0.0" matcher: ^0.12.0 merge_map: ^1.0.0 @@ -29,6 +30,7 @@ dependencies: path: ^1.0.0 stack_trace: ^1.0.0 tuple: ^1.0.0 + uuid: ^1.0.0 dev_dependencies: http: ^0.11.3 io: ^0.3.0 diff --git a/test/http2/adapter_test.dart b/test/http2/adapter_test.dart new file mode 100644 index 00000000..1543516a --- /dev/null +++ b/test/http2/adapter_test.dart @@ -0,0 +1,265 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart' hide Header; +import 'package:angel_framework/http2.dart'; +import 'package:http/src/multipart_file.dart' as http; +import 'package:http/src/multipart_request.dart' as http; +import 'package:http/http.dart' as http; +import 'package:http2/transport.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:test/test.dart'; +import 'http2_client.dart'; + +const String jfk = + 'Ask not what your country can do for you, but what you can do for your country.'; + +Stream> jfkStream() { + return new Stream.fromIterable([utf8.encode(jfk)]); +} + +void main() { + var client = new Http2Client(); + Angel app; + AngelHttp2 http2; + Uri serverRoot; + + setUp(() async { + app = new Angel() + ..keepRawRequestBuffers = true + ..encoders['gzip'] = gzip.encoder; + + app.get('/', (req, res) { + res + ..write('Hello world') + ..close(); + }); + + app.all('/method', (req, res) => req.method); + + app.get('/json', (_, __) => {'foo': 'bar'}); + + app.get('/stream', (req, res) => jfkStream().pipe(res)); + + app.get('/headers', (req, res) { + res + ..headers.addAll({'foo': 'bar', 'x-angel': 'http2'}) + ..close(); + }); + + app.get('/status', (req, res) { + res + ..statusCode = 1337 + ..close(); + }); + + app.post('/body', (req, res) => req.parseBody()); + + app.post('/upload', (req, res) async { + var body = await req.parseBody(), files = await req.parseUploadedFiles(); + stdout.add(await req.parseRawRequestBuffer()); + var file = files.firstWhere((f) => f.name == 'file'); + return [file.data.length, file.mimeType, body]; + }); + + app.get('/push', (req, res) async { + res + ..write('ok') + ..close(); + + if (res is Http2ResponseContext && res.canPush) { + res.push('a') + ..write('a') + ..close(); + + res.push('b') + ..write('b') + ..close(); + } + }); + + var ctx = new SecurityContext() + ..useCertificateChain('dev.pem') + ..usePrivateKey('dev.key', password: 'dartdart') + ..setAlpnProtocols(['h2'], true); + + http2 = new AngelHttp2(app, ctx); + + var server = await http2.startServer(); + serverRoot = Uri.parse('https://127.0.0.1:${server.port}'); + }); + + tearDown(() async { + await http2.close(); + }); + + test('buffered response', () async { + var response = await client.get(serverRoot); + expect(response.body, 'Hello world'); + }); + + test('streamed response', () async { + var response = await client.get(serverRoot.replace(path: '/stream')); + expect(response.body, jfk); + }); + + group('gzip', () { + test('buffered response', () async { + var response = await client + .get(serverRoot, headers: {'accept-encoding': 'gzip, deflate, br'}); + expect(response.headers['content-encoding'], 'gzip'); + var decoded = gzip.decode(response.bodyBytes); + expect(utf8.decode(decoded), 'Hello world'); + }); + + test('streamed response', () async { + var response = await client.get(serverRoot.replace(path: '/stream'), + headers: {'accept-encoding': 'gzip'}); + expect(response.headers['content-encoding'], 'gzip'); + //print(response.body); + var decoded = gzip.decode(response.bodyBytes); + expect(utf8.decode(decoded), jfk); + }); + }); + + test('method parsed', () async { + var response = await client.delete(serverRoot.replace(path: '/method')); + expect(response.body, json.encode('DELETE')); + }); + + test('json response', () async { + var response = await client.get(serverRoot.replace(path: '/json')); + expect(response.body, json.encode({'foo': 'bar'})); + expect(ContentType.parse(response.headers['content-type']).mimeType, + ContentType.json.mimeType); + }); + + test('status sent', () async { + var response = await client.get(serverRoot.replace(path: '/status')); + expect(response.statusCode, 1337); + }); + + test('headers sent', () async { + var response = await client.get(serverRoot.replace(path: '/headers')); + expect(response.headers['foo'], 'bar'); + expect(response.headers['x-angel'], 'http2'); + }); + + test('server push', () async { + var socket = await SecureSocket.connect( + serverRoot.host, + serverRoot.port ?? 443, + onBadCertificate: (_) => true, + supportedProtocols: ['h2'], + ); + + var connection = new ClientTransportConnection.viaSocket( + socket, + settings: new ClientSettings(allowServerPushes: true), + ); + + var headers =
[ + new Header.ascii(':authority', serverRoot.authority), + new Header.ascii(':method', 'GET'), + new Header.ascii(':path', serverRoot.replace(path: '/push').path), + new Header.ascii(':scheme', serverRoot.scheme), + ]; + + var stream = await connection.makeRequest(headers, endStream: true); + + var bb = await stream.incomingMessages + .where((s) => s is DataStreamMessage) + .cast() + .fold( + new BytesBuilder(), (out, msg) => out..add(msg.bytes)); + + // Check that main body was sent + expect(utf8.decode(bb.takeBytes()), 'ok'); + + var pushes = await stream.peerPushes.toList(); + expect(pushes, hasLength(2)); + + var pushA = pushes[0], pushB = pushes[1]; + + String getPath(TransportStreamPush p) => ascii.decode(p.requestHeaders + .firstWhere((h) => ascii.decode(h.name) == ':path') + .value); + + /* + Future getBody(ClientTransportStream stream) async { + await stream.outgoingMessages.close(); + var bb = await stream.incomingMessages + .map((s) { + if (s is HeadersStreamMessage) { + for (var h in s.headers) { + print('${ASCII.decode(h.name)}: ${ASCII.decode(h.value)}'); + } + } else if (s is DataStreamMessage) { + print(UTF8.decode(s.bytes)); + } + + return s; + }) + .where((s) => s is DataStreamMessage) + .cast() + .fold( + new BytesBuilder(), (out, msg) => out..add(msg.bytes)); + return UTF8.decode(bb.takeBytes()); + } + */ + + expect(getPath(pushA), '/a'); + expect(getPath(pushB), '/b'); + + // TODO: Dart http/2 client seems to not be able to get body + // However, Chrome, Firefox, Edge all can + //expect(await getBody(pushA.stream), 'a'); + //expect(await getBody(pushB.stream), 'b'); + }); + + group('body parsing', () { + test('urlencoded body parsed', () async { + var response = await client.post( + serverRoot.replace(path: '/body'), + headers: { + 'accept': 'application/json', + 'content-type': 'application/x-www-form-urlencoded' + }, + body: 'foo=bar', + ); + expect(response.body, json.encode({'foo': 'bar'})); + }); + + test('json body parsed', () async { + var response = await client.post(serverRoot.replace(path: '/body'), + headers: { + 'accept': 'application/json', + 'content-type': 'application/json' + }, + body: json.encode({'foo': 'bar'})); + expect(response.body, json.encode({'foo': 'bar'})); + }); + + test('multipart body parsed', () async { + var rq = new http.MultipartRequest( + 'POST', serverRoot.replace(path: '/upload')); + rq.headers.addAll({'accept': 'application/json'}); + + rq.fields['foo'] = 'bar'; + rq.files.add(new http.MultipartFile( + 'file', new Stream.fromIterable([utf8.encode('hello world')]), 11, + contentType: new MediaType('angel', 'framework'))); + + var response = await client.send(rq); + var responseBody = await response.stream.transform(utf8.decoder).join(); + + expect( + responseBody, + json.encode([ + 11, + 'angel/framework', + {'foo': 'bar'} + ])); + }); + }); +} diff --git a/test/http2/http2_client.dart b/test/http2/http2_client.dart new file mode 100644 index 00000000..5263d2b7 --- /dev/null +++ b/test/http2/http2_client.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart'; +import 'package:http2/transport.dart'; + +/// Simple HTTP/2 client +class Http2Client extends BaseClient { + static Future convertRequestToStream( + BaseRequest request) async { + // Connect a socket + var socket = await SecureSocket.connect( + request.url.host, + request.url.port ?? 443, + onBadCertificate: (_) => true, + supportedProtocols: ['h2'], + ); + + var connection = new ClientTransportConnection.viaSocket(socket); + + var headers =
[ + new Header.ascii(':authority', request.url.authority), + new Header.ascii(':method', request.method), + new Header.ascii(':path', request.url.path), + new Header.ascii(':scheme', request.url.scheme), + ]; + + var bb = await request + .finalize() + .fold(new BytesBuilder(), (out, list) => out..add(list)); + var body = bb.takeBytes(); + + if (body.isNotEmpty) { + headers.add(new Header.ascii('content-length', body.length.toString())); + } + + request.headers.forEach((k, v) { + headers.add(new Header.ascii(k, v)); + }); + + var stream = await connection.makeRequest(headers); + + if (body.isNotEmpty) { + stream.sendData(body, endStream: true); + } else { + stream.outgoingMessages.close(); + } + + return stream; + } + + /// Returns `true` if the response stream was closed. + static Future readResponse(ClientTransportStream stream, + Map headers, BytesBuilder body) { + var c = new Completer(); + var closed = false; + + stream.incomingMessages.listen( + (msg) { + if (msg is HeadersStreamMessage) { + for (var header in msg.headers) { + var name = ascii.decode(header.name).toLowerCase(), + value = ascii.decode(header.value); + headers[name] = value; + //print('$name: $value'); + } + } else if (msg is DataStreamMessage) { + body.add(msg.bytes); + } + + if (!closed && msg.endStream) closed = true; + }, + cancelOnError: true, + onError: c.completeError, + onDone: () => c.complete(closed), + ); + + return c.future; + } + + @override + Future send(BaseRequest request) async { + var stream = await convertRequestToStream(request); + var headers = {}; + var body = new BytesBuilder(); + var closed = await readResponse(stream, headers, body); + return new StreamedResponse( + new Stream.fromIterable([body.takeBytes()]), + int.parse(headers[':status']), + headers: headers, + isRedirect: headers.containsKey('location'), + contentLength: headers.containsKey('content-length') + ? int.parse(headers['content-length']) + : null, + request: request, + reasonPhrase: null, + // doesn't exist in HTTP/2 + persistentConnection: !closed, + ); + } +} diff --git a/test/services_test.dart b/test/services_test.dart index 4a8c601e..d5e4e7d0 100644 --- a/test/services_test.dart +++ b/test/services_test.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:angel_container/mirrors.dart'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_framework/http.dart';