diff --git a/packages/proxy/.gitignore b/packages/proxy/.gitignore new file mode 100644 index 00000000..fffcccb7 --- /dev/null +++ b/packages/proxy/.gitignore @@ -0,0 +1,71 @@ +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.buildlog +.packages +.project +.pub/ +build/ +**/packages/ + +# Files created by dart2js +# (Most Dart developers will use pub build to compile Dart, use/modify these +# rules if you intend to use dart2js directly +# Convention is to use extension '.dart.js' for Dart compiled to Javascript to +# differentiate from explicit Javascript files) +*.dart.js +*.part.js +*.js.deps +*.js.map +*.info.json + +# Directory created by dartdoc +doc/api/ + +# Don't commit pubspec lock file +# (Library packages only! Remove pattern if developing an application package) +pubspec.lock +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/workspace.xml +.idea/tasks.xml + +# Sensitive or high-churn files: +.idea/dataSources/ +.idea/dataSources.ids +.idea/dataSources.xml +.idea/dataSources.local.xml +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml + +# Gradle: +.idea/gradle.xml +.idea/libraries + +# Mongo Explorer plugin: +.idea/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +.dart_tool diff --git a/packages/proxy/.idea/angel_proxy.iml b/packages/proxy/.idea/angel_proxy.iml new file mode 100644 index 00000000..eae13016 --- /dev/null +++ b/packages/proxy/.idea/angel_proxy.iml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/proxy/.idea/jsLibraryMappings.xml b/packages/proxy/.idea/jsLibraryMappings.xml new file mode 100644 index 00000000..f3e502d1 --- /dev/null +++ b/packages/proxy/.idea/jsLibraryMappings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/proxy/.idea/misc.xml b/packages/proxy/.idea/misc.xml new file mode 100644 index 00000000..c65900a0 --- /dev/null +++ b/packages/proxy/.idea/misc.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + General + + + XPath + + + + + AngularJS + + + + + + \ No newline at end of file diff --git a/packages/proxy/.idea/modules.xml b/packages/proxy/.idea/modules.xml new file mode 100644 index 00000000..a68a6387 --- /dev/null +++ b/packages/proxy/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/proxy/.idea/runConfigurations/All_Tests.xml b/packages/proxy/.idea/runConfigurations/All_Tests.xml new file mode 100644 index 00000000..44742797 --- /dev/null +++ b/packages/proxy/.idea/runConfigurations/All_Tests.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/packages/proxy/.idea/runConfigurations/Basic_Tests.xml b/packages/proxy/.idea/runConfigurations/Basic_Tests.xml new file mode 100644 index 00000000..87d3c79c --- /dev/null +++ b/packages/proxy/.idea/runConfigurations/Basic_Tests.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/packages/proxy/.idea/runConfigurations/Pub_Serve_Tests.xml b/packages/proxy/.idea/runConfigurations/Pub_Serve_Tests.xml new file mode 100644 index 00000000..74e5219a --- /dev/null +++ b/packages/proxy/.idea/runConfigurations/Pub_Serve_Tests.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/packages/proxy/.idea/runConfigurations/multiple_dart.xml b/packages/proxy/.idea/runConfigurations/multiple_dart.xml new file mode 100644 index 00000000..36b7b1d6 --- /dev/null +++ b/packages/proxy/.idea/runConfigurations/multiple_dart.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/packages/proxy/.idea/vcs.xml b/packages/proxy/.idea/vcs.xml new file mode 100644 index 00000000..94a25f7f --- /dev/null +++ b/packages/proxy/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/proxy/.travis.yml b/packages/proxy/.travis.yml new file mode 100644 index 00000000..de2210c9 --- /dev/null +++ b/packages/proxy/.travis.yml @@ -0,0 +1 @@ +language: dart \ No newline at end of file diff --git a/packages/proxy/.vscode/launch.json b/packages/proxy/.vscode/launch.json new file mode 100644 index 00000000..b78e04ca --- /dev/null +++ b/packages/proxy/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Multiple Proxies", + "type": "dart-cli", + "request": "launch", + "program": "${workspaceRoot}/example/multiple.dart" + } + ] +} \ No newline at end of file diff --git a/packages/proxy/CHANGELOG.md b/packages/proxy/CHANGELOG.md new file mode 100644 index 00000000..94651d01 --- /dev/null +++ b/packages/proxy/CHANGELOG.md @@ -0,0 +1,26 @@ +# 2.2.0 +* Use `http.Client` instead of `http.BaseClient`, and make it an +optional parameter. +* Allow `baseUrl` to accept `Uri` or `String`. +* Add `Proxy.pushState`. + +# 2.1.2 +* Apply lints. + +# 2.1.1 +* Update for framework@2.0.0-alpha.15 + +# 2.1.0 + +- Use `Uri` instead of archaic `host`, `port`, and `mapTo`. Also cleaner + safer + easier. + +* Enable WebSocket proxying. + +# 2.0.0 + +- Updates for Angel 2. Big thanks to @denkuy! +- Use `package:path` for better path resolution. + +# 1.1.1 + +- Removed reference to `io`; now works with HTTP/2. Thanks to @daniel-v! diff --git a/packages/proxy/LICENSE b/packages/proxy/LICENSE new file mode 100644 index 00000000..15fe44bd --- /dev/null +++ b/packages/proxy/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 The Angel Framework + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/proxy/README.md b/packages/proxy/README.md new file mode 100644 index 00000000..7e4400c9 --- /dev/null +++ b/packages/proxy/README.md @@ -0,0 +1,34 @@ +# proxy +[![Pub](https://img.shields.io/pub/v/angel_proxy.svg)](https://pub.dartlang.org/packages/angel_proxy) +[![build status](https://travis-ci.org/angel-dart/proxy.svg)](https://travis-ci.org/angel-dart/proxy) + +Angel middleware to forward requests to another server (i.e. `webdev serve`). +Also supports WebSockets. + +```dart +import 'package:angel_proxy/angel_proxy.dart'; +import 'package:http/http.dart' as http; + +main() async { + // Forward requests instead of serving statically. + // You can also pass a URI, instead of a string. + var proxy1 = Proxy('http://localhost:3000'); + + // handle all methods (GET, POST, ...) + app.fallback(proxy.handleRequest); +} +``` + +You can also restrict the proxy to serving only from a specific root: +```dart +Proxy(baseUrl, publicPath: '/remote'); +``` + +Also, you can map requests to a root path on the remote server: +```dart +Proxy(baseUrl.replace(path: '/path')); +``` + +Request bodies will be forwarded as well, if they are not empty. This allows things like POST requests to function. + +For a request body to be forwarded, the body must not have already been parsed. \ No newline at end of file diff --git a/packages/proxy/analysis_options.yaml b/packages/proxy/analysis_options.yaml new file mode 100644 index 00000000..085be64d --- /dev/null +++ b/packages/proxy/analysis_options.yaml @@ -0,0 +1,8 @@ +include: package:pedantic/analysis_options.yaml +analyzer: + strong-mode: + implicit-casts: false +linter: + rules: + - unnecessary_const + - unnecessary_new \ No newline at end of file diff --git a/packages/proxy/example/main.dart b/packages/proxy/example/main.dart new file mode 100644 index 00000000..f4998ef6 --- /dev/null +++ b/packages/proxy/example/main.dart @@ -0,0 +1,63 @@ +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_proxy/angel_proxy.dart'; +import 'package:logging/logging.dart'; + +final Duration timeout = Duration(seconds: 5); + +main() async { + var app = Angel(); + + // Forward any /api requests to pub. + // By default, if the host throws a 404, the request will fall through to the next handler. + var pubProxy = Proxy( + 'https://pub.dartlang.org', + publicPath: '/pub', + timeout: timeout, + ); + app.all("/pub/*", pubProxy.handleRequest); + + // Surprise! We can also proxy WebSockets. + // + // Play around with this at http://www.websocket.org/echo.html. + var echoProxy = Proxy( + 'http://echo.websocket.org', + publicPath: '/echo', + timeout: timeout, + ); + app.get('/echo', echoProxy.handleRequest); + + // Pub's HTML assumes that the site's styles, etc. are on the absolute path `/static`. + // This is not the case here. Let's patch that up: + app.get('/static/*', (RequestContext req, res) { + return pubProxy.servePath(req.path, req, res); + }); + + // Anything else should fall through to dartlang.org. + var dartlangProxy = Proxy( + 'https://dartlang.org', + timeout: timeout, + recoverFrom404: false, + ); + app.all('*', dartlangProxy.handleRequest); + + // In case we can't connect to dartlang.org, show an error. + app.fallback( + (req, res) => res.write('Couldn\'t connect to Pub or dartlang.')); + + app.logger = Logger('angel') + ..onRecord.listen( + (rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }, + ); + + var server = + await AngelHttp(app).startServer(InternetAddress.loopbackIPv4, 8080); + print('Listening at http://${server.address.address}:${server.port}'); + print( + 'Check this out! http://${server.address.address}:${server.port}/pub/packages/angel_framework'); +} diff --git a/packages/proxy/lib/angel_proxy.dart b/packages/proxy/lib/angel_proxy.dart new file mode 100644 index 00000000..a0138f3e --- /dev/null +++ b/packages/proxy/lib/angel_proxy.dart @@ -0,0 +1,3 @@ +library angel_proxy; + +export 'src/proxy_layer.dart'; diff --git a/packages/proxy/lib/src/proxy_layer.dart b/packages/proxy/lib/src/proxy_layer.dart new file mode 100644 index 00000000..dc1e87b3 --- /dev/null +++ b/packages/proxy/lib/src/proxy_layer.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:convert'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; + +final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)'); +final MediaType _fallbackMediaType = MediaType('application', 'octet-stream'); + +/// A middleware class that forwards requests (reverse proxies) to an upstream server. +/// +/// Supports WebSockets, in addition to regular HTTP requests. +class Proxy { + String _prefix; + + /// The underlying [Client] to use. + final http.Client httpClient; + + /// If `true` (default), then the plug-in will ignore failures to connect to the proxy, and allow other handlers to run. + final bool recoverFromDead; + final bool recoverFrom404; + final Uri baseUrl; + final String publicPath; + + /// If `null` then no timout is added for requests + final Duration timeout; + + Proxy( + baseUrl, { + http.Client httpClient, + this.publicPath = '/', + this.recoverFromDead = true, + this.recoverFrom404 = true, + this.timeout, + }) : this.baseUrl = baseUrl is Uri ? baseUrl : Uri.parse(baseUrl.toString()), + this.httpClient = httpClient ?? http.Client() { + if (!this.baseUrl.hasScheme || !this.baseUrl.hasAuthority) { + throw ArgumentError( + 'Invalid `baseUrl`. URI must have both a scheme and authority.'); + } + if (this.recoverFromDead == null) { + throw ArgumentError.notNull("recoverFromDead"); + } + if (this.recoverFrom404 == null) { + throw ArgumentError.notNull("recoverFrom404"); + } + + _prefix = publicPath?.replaceAll(_straySlashes, '') ?? ''; + } + + void close() => httpClient.close(); + + /// A handler that serves the file at the given path, unless the user has requested that path. + /// + /// You can also limit this functionality to specific values of the `Accept` header, ex. `text/html`. + /// If [accepts] is `null`, OR at least one of the content types in [accepts] is present, + /// the view will be served. + RequestHandler pushState(String path, {Iterable accepts}) { + var vPath = path.replaceAll(_straySlashes, ''); + if (_prefix?.isNotEmpty == true) vPath = '$_prefix/$vPath'; + + return (RequestContext req, ResponseContext res) { + var path = req.path.replaceAll(_straySlashes, ''); + if (path == vPath) return Future.value(true); + + if (accepts?.isNotEmpty == true) { + if (!accepts.any((x) => req.accepts(x, strict: true))) { + return Future.value(true); + } + } + + return servePath(vPath, req, res); + }; + } + + /// Handles an incoming HTTP request. + Future handleRequest(RequestContext req, ResponseContext res) { + var path = req.path.replaceAll(_straySlashes, ''); + + if (_prefix.isNotEmpty) { + if (!p.isWithin(_prefix, path) && !p.equals(_prefix, path)) { + return Future.value(true); + } + + path = p.relative(path, from: _prefix); + } + + return servePath(path, req, res); + } + + /// Proxies a request to the given path on the remote server. + Future servePath( + String path, RequestContext req, ResponseContext res) async { + http.StreamedResponse rs; + + var uri = baseUrl.replace(path: p.join(baseUrl.path, path)); + + try { + if (req is HttpRequestContext && + WebSocketTransformer.isUpgradeRequest(req.rawRequest)) { + res.detach(); + uri = uri.replace(scheme: uri.scheme == 'https' ? 'wss' : 'ws'); + + try { + var local = await WebSocketTransformer.upgrade(req.rawRequest); + var remote = await WebSocket.connect(uri.toString()); + + scheduleMicrotask(() => local.pipe(remote)); + scheduleMicrotask(() => remote.pipe(local)); + return false; + } catch (e, st) { + throw AngelHttpException(e, + message: 'Could not connect WebSocket', stackTrace: st); + } + } + + Future accessRemote() async { + var headers = { + 'host': uri.authority, + 'x-forwarded-for': req.remoteAddress.address, + 'x-forwarded-port': req.uri.port.toString(), + 'x-forwarded-host': + req.headers.host ?? req.headers.value('host') ?? 'none', + 'x-forwarded-proto': uri.scheme, + }; + + req.headers.forEach((name, values) { + headers[name] = values.join(','); + }); + + headers[HttpHeaders.cookieHeader] = + req.cookies.map((c) => '${c.name}=${c.value}').join('; '); + + List body; + + if (!req.hasParsedBody) { + body = await req.body + .fold(BytesBuilder(), (bb, buf) => bb..add(buf)) + .then((bb) => bb.takeBytes()); + } + + var rq = http.Request(req.method, uri); + rq.headers.addAll(headers); + rq.headers['host'] = rq.url.host; + rq.encoding = Utf8Codec(allowMalformed: true); + + if (body != null) rq.bodyBytes = body; + + return httpClient.send(rq); + } + + var future = accessRemote(); + if (timeout != null) future = future.timeout(timeout); + rs = await future; + } on TimeoutException catch (e, st) { + if (recoverFromDead) return true; + + throw AngelHttpException( + e, + stackTrace: st, + statusCode: 504, + message: + 'Connection to remote host "$uri" timed out after ${timeout.inMilliseconds}ms.', + ); + } catch (e) { + if (recoverFromDead && e is! AngelHttpException) return true; + rethrow; + } + + if (rs.statusCode == 404 && recoverFrom404) return true; + if (rs.contentLength == 0 && recoverFromDead) return true; + + MediaType mediaType; + if (rs.headers.containsKey(HttpHeaders.contentTypeHeader)) { + try { + mediaType = MediaType.parse(rs.headers[HttpHeaders.contentTypeHeader]); + } on FormatException catch (e, st) { + if (recoverFromDead) return true; + + throw AngelHttpException( + e, + stackTrace: st, + statusCode: 504, + message: 'Host "$uri" returned a malformed content-type', + ); + } + } else { + mediaType = _fallbackMediaType; + } + + /// if [http.Client] does not provide us with a content length + /// OR [http.Client] is about to decode the response (bytecount returned by [http.Response].stream != known length) + /// then we can not provide a value downstream => set to '-1' for 'unspecified length' + var isContentLengthUnknown = rs.contentLength == null || + rs.headers[HttpHeaders.contentEncodingHeader]?.isNotEmpty == true || + rs.headers[HttpHeaders.transferEncodingHeader]?.isNotEmpty == true; + + var proxiedHeaders = Map.from(rs.headers) + ..remove( + HttpHeaders.contentEncodingHeader) // drop, http.Client has decoded + ..remove( + HttpHeaders.transferEncodingHeader) // drop, http.Client has decoded + ..[HttpHeaders.contentLengthHeader] = + "${isContentLengthUnknown ? '-1' : rs.contentLength}"; + + res + ..contentType = mediaType + ..statusCode = rs.statusCode + ..headers.addAll(proxiedHeaders); + + await rs.stream.pipe(res); + + return false; + } +} diff --git a/packages/proxy/pubspec.yaml b/packages/proxy/pubspec.yaml new file mode 100644 index 00000000..a319e2a9 --- /dev/null +++ b/packages/proxy/pubspec.yaml @@ -0,0 +1,19 @@ +name: angel_proxy +description: Angel middleware to forward requests to another server (i.e. pub serve). +version: 2.2.0 +author: Tobe O +homepage: https://github.com/angel-dart/proxy +environment: + sdk: ">=2.0.0 <3.0.0" +dependencies: + angel_framework: ^2.0.0-alpha + http: ^0.12.0 + http_parser: ^3.0.0 + path: ^1.0.0 +dev_dependencies: + angel_test: ^2.0.0-alpha + logging: + mock_request: + pedantic: ^1.0.0 + test: ^1.0.0 + diff --git a/packages/proxy/test/basic_test.dart b/packages/proxy/test/basic_test.dart new file mode 100644 index 00000000..6d62d82d --- /dev/null +++ b/packages/proxy/test/basic_test.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_proxy/angel_proxy.dart'; +import 'package:http/io_client.dart' as http; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; +import 'common.dart'; + +main() { + Angel app; + var client = http.IOClient(); + HttpServer server, testServer; + String url; + + setUp(() async { + app = Angel(); + var appHttp = AngelHttp(app); + + testServer = await startTestServer(); + + var proxy1 = Proxy( + Uri( + scheme: 'http', + host: testServer.address.address, + port: testServer.port), + publicPath: '/proxy', + ); + + var proxy2 = Proxy(proxy1.baseUrl.replace(path: '/foo')); + print('Proxy 1 on: ${proxy1.baseUrl}'); + print('Proxy 2 on: ${proxy2.baseUrl}'); + + app.all("/proxy/*", proxy1.handleRequest); + app.all("*", proxy2.handleRequest); + + app.fallback((req, res) { + print('Intercepting empty from ${req.uri}'); + res.write('intercept empty'); + }); + + app.logger = Logger('angel'); + + Logger.root.onRecord.listen((rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }); + + await appHttp.startServer(); + url = appHttp.uri.toString(); + }); + + tearDown(() async { + await testServer?.close(force: true); + await server?.close(force: true); + app = null; + url = null; + }); + + test('publicPath', () async { + final response = await client.get('$url/proxy/hello'); + print('Response: ${response.body}'); + expect(response.body, equals('world')); + }); + + test('empty', () async { + var response = await client.get('$url/proxy/empty'); + print('Response: ${response.body}'); + expect(response.body, 'intercept empty'); + }); + + test('mapTo', () async { + final response = await client.get('$url/bar'); + print('Response: ${response.body}'); + expect(response.body, equals('baz')); + }); + + test('original buffer', () async { + var response = await client.post('$url/proxy/body', + body: json.encode({'foo': 'bar'}), + headers: {'content-type': 'application/json'}); + print('Response: ${response.body}'); + expect(response.body, isNotEmpty); + expect(response.body, isNot('intercept empty')); + }); +} diff --git a/packages/proxy/test/common.dart b/packages/proxy/test/common.dart new file mode 100644 index 00000000..c437361c --- /dev/null +++ b/packages/proxy/test/common.dart @@ -0,0 +1,24 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:logging/logging.dart'; + +Future startTestServer() { + final app = Angel(); + + app.get('/hello', (req, res) => res.write('world')); + app.get('/foo/bar', (req, res) => res.write('baz')); + app.post('/body', (RequestContext req, res) async { + var body = await req.parseBody().then((_) => req.bodyAsMap); + app.logger.info('Body: $body'); + return body; + }); + + app.logger = Logger('testApp'); + var server = AngelHttp(app); + app.dumpTree(); + + return server.startServer(); +} diff --git a/packages/proxy/test/pub_serve_test.dart b/packages/proxy/test/pub_serve_test.dart new file mode 100644 index 00000000..48cefa4e --- /dev/null +++ b/packages/proxy/test/pub_serve_test.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_proxy/angel_proxy.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:logging/logging.dart'; +import 'package:mock_request/mock_request.dart'; +import 'package:test/test.dart'; + +main() { + Angel app, testApp; + TestClient client; + Proxy layer; + + setUp(() async { + testApp = Angel(); + testApp.get('/foo', (req, res) async { + res.useBuffer(); + res.write('pub serve'); + }); + testApp.get('/empty', (req, res) => res.close()); + + testApp.responseFinalizers.add((req, res) async { + print('OUTGOING: ' + String.fromCharCodes(res.buffer.toBytes())); + }); + + testApp.encoders.addAll({'gzip': gzip.encoder}); + + var server = await AngelHttp(testApp).startServer(); + + app = Angel(); + app.fallback((req, res) { + res.useBuffer(); + return true; + }); + app.get('/bar', (req, res) => res.write('normal')); + + layer = Proxy( + Uri(scheme: 'http', host: server.address.address, port: server.port), + publicPath: '/proxy', + ); + + app.fallback(layer.handleRequest); + + app.responseFinalizers.add((req, res) async { + print('Normal. Buf: ' + + String.fromCharCodes(res.buffer.toBytes()) + + ', headers: ${res.headers}'); + }); + + app.encoders.addAll({'gzip': gzip.encoder}); + + client = await connectTo(app); + + app.logger = testApp.logger = Logger('proxy') + ..onRecord.listen((rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }); + }); + + tearDown(() async { + await client.close(); + await app.close(); + await testApp.close(); + app = null; + testApp = null; + }); + + test('proxied', () async { + var rq = MockHttpRequest('GET', Uri.parse('/proxy/foo')); + await rq.close(); + var rqc = await HttpRequestContext.from(rq, app, '/proxy/foo'); + var rsc = HttpResponseContext(rq.response, app); + await app.executeHandler(layer.handleRequest, rqc, rsc); + var response = await rq.response + //.transform(gzip.decoder) + .transform(utf8.decoder) + .join(); + expect(response, 'pub serve'); + }); + + test('empty', () async { + var response = await client.get('/proxy/empty'); + expect(response.body, isEmpty); + }); + + test('normal', () async { + var response = await client.get('/bar'); + expect(response, hasBody('normal')); + }); +}