1.1.0-alpha

This commit is contained in:
Tobe O 2017-09-24 14:49:18 -04:00
parent 487e825f8d
commit 4d7cbb679e
13 changed files with 216 additions and 260 deletions

View file

@ -5,10 +5,7 @@
<excludeFolder url="file://$MODULE_DIR$/.pub" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/example/packages" />
<excludeFolder url="file://$MODULE_DIR$/packages" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/test/packages" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />

View file

@ -2,6 +2,7 @@
<configuration default="false" name="All Tests" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true">
<option name="filePath" value="$PROJECT_DIR$" />
<option name="scope" value="FOLDER" />
<option name="testRunnerOptions" value="-j 4" />
<method />
</configuration>
</component>

View file

@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="multiple.dart" type="DartCommandLineRunConfigurationType" factoryName="Dart Command Line Application" singleton="true" nameIsGenerated="true">
<option name="filePath" value="$PROJECT_DIR$/example/multiple.dart" />
<option name="workingDirectory" value="$PROJECT_DIR$" />
<method />
</configuration>
</component>

View file

@ -1,25 +1,33 @@
# angel_proxy
# 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)
[![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. pub serve).
Angel middleware to forward requests to another server (i.e. `pub serve`).
```dart
import 'package:angel_proxy/angel_proxy.dart';
import 'package:http/http.dart' as http;
main() async {
// ...
// Forward requests instead of serving statically
await app.configure(new ProxyLayer('localhost', 3000));
var client = new http.Client();
var proxy = new Proxy(app, client, 'http://localhost:3000');
// Or, use one for pub serve.
//
// This automatically deactivates itself if the app is
// in production.
await app.configure(new PubServeLayer());
// Forward requests instead of serving statically
app.use(proxy.handleRequest);
}
```
You can also restrict the proxy to serving only from a specific root:
```dart
new Proxy(app, client, '<host>', publicPath: '/remote');
```
Also, you can map requests to a root path on the remote server
```dart
new Proxy(app, client, '<host>', mapTo: '/path');
```
If your app's `storeOriginalBuffer` is `true`, then request bodies will be forwarded
as well, if they are not empty. This allows things like POST requests to function.

View file

@ -1,38 +1,55 @@
import 'dart:io';
import 'package:angel_diagnostics/angel_diagnostics.dart';
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_proxy/angel_proxy.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
final Duration TIMEOUT = new Duration(seconds: 5);
final Duration timeout = new Duration(seconds: 5);
main() async {
var app = new Angel();
var client = new http.Client();
// 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 = new ProxyLayer('pub.dartlang.org', 80,
publicPath: '/pub', timeout: TIMEOUT);
await app.configure(pubProxy);
var pubProxy = new Proxy(
app,
client,
'https://pub.dartlang.org',
publicPath: '/pub',
timeout: timeout,
);
app.use(pubProxy.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.serveFile(req.path, req, res);
return pubProxy.servePath(req.path, req, res);
});
// Anything else should fall through to dartlang.org.
await app.configure(new ProxyLayer('dartlang.org', 80, timeout: TIMEOUT));
var dartlangProxy = new Proxy(
app,
client,
'https://dartlang.org',
timeout: timeout,
recoverFrom404: false,
);
app.use(dartlangProxy.handleRequest);
// In case we can't connect to dartlang.org, show an error.
app.after.add('Couldn\'t connect to Pub or dartlang.');
app.use('Couldn\'t connect to Pub or dartlang.');
await app.configure(logRequests());
app.fatalErrorStream.listen((AngelFatalError e) {
print(e.error);
print(e.stack);
});
app.logger = new Logger('angel')
..onRecord.listen(
(rec) {
print(rec);
if (rec.error != null) print(rec.error);
if (rec.stackTrace != null) print(rec.stackTrace);
},
);
var server = await app.startServer(InternetAddress.LOOPBACK_IP_V4, 8080);
print('Listening at http://${server.address.address}:${server.port}');
print('Check this out! http://${server.address.address}:${server.port}/pub/packages/angel_framework');
}

View file

@ -1,4 +1,3 @@
library angel_proxy;
export 'src/proxy_layer.dart';
export 'src/pub_serve_layer.dart';

View file

@ -1,142 +1,107 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:angel_framework/angel_framework.dart';
import 'package:http/src/base_client.dart' as http;
import 'package:http/src/request.dart' as http;
import 'package:http/src/response.dart' as http;
import 'package:http/src/streamed_response.dart' as http;
final RegExp _param = new RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?');
final RegExp _straySlashes = new RegExp(r'(^/+)|(/+$)');
/// Used to mount a route for a [ProxyLayer].
typedef Route ProxyLayerRouteAssigner(Router router, String path, handler);
String _pathify(String path) {
var p = path.replaceAll(_straySlashes, '');
Map<String, String> replace = {};
for (Match match in _param.allMatches(p)) {
if (match[3] != null) replace[match[0]] = ':${match[1]}';
}
replace.forEach((k, v) {
p = p.replaceAll(k, v);
});
return p;
}
/// Copies HTTP headers ;)
void copyHeaders(HttpHeaders from, HttpHeaders to) {
from.forEach((k, v) {
if (k != HttpHeaders.SERVER &&
(k != HttpHeaders.CONTENT_ENCODING || !v.contains('gzip')))
to.set(k, v);
});
/*to
..chunkedTransferEncoding = from.chunkedTransferEncoding
..contentLength = from.contentLength
..contentType = from.contentType
..date = from.date
..expires = from.expires
..host = from.host
..ifModifiedSince = from.ifModifiedSince
..persistentConnection = from.persistentConnection
..port = from.port;*/
}
class ProxyLayer {
Angel app;
HttpClient _client;
class Proxy {
String _prefix;
final Angel app;
final http.BaseClient 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 debug, recoverFrom404, streamToIO;
final bool recoverFrom404;
final String host, mapTo, publicPath;
final int port;
final String protocol;
final Duration timeout;
ProxyLayerRouteAssigner routeAssigner;
ProxyLayer(
this.host,
this.port, {
this.debug: false,
Proxy(
this.app,
this.httpClient,
this.host, {
this.port,
this.mapTo: '/',
this.publicPath: '/',
this.protocol: 'http',
this.recoverFromDead: true,
this.recoverFrom404: true,
this.streamToIO: false,
this.timeout,
this.routeAssigner,
SecurityContext securityContext,
}) {
_client = new HttpClient(context: securityContext);
_prefix = publicPath.replaceAll(_straySlashes, '');
routeAssigner ??=
(Router router, String path, handler) => router.get(path, handler);
}
call(Angel app) async => serve(this.app = app);
void close() => httpClient.close();
void close() => _client.close(force: true);
/// Handles an incoming HTTP request.
Future<bool> handleRequest(RequestContext req, ResponseContext res) {
var path = req.path.replaceAll(_straySlashes, '');
void serve(Router router) {
// _printDebug('Public path prefix: "$_prefix"');
handler(RequestContext req, ResponseContext res) async {
var path = req.path.replaceAll(_straySlashes, '');
return serveFile(path, req, res);
if (_prefix?.isNotEmpty == true) {
if (!path.startsWith(_prefix))
return new Future<bool>.value(true);
else {
path = path.replaceFirst(_prefix, '').replaceAll(_straySlashes, '');
}
}
routeAssigner(router, '$publicPath/*', handler);
return servePath(path, req, res);
}
serveFile(String path, RequestContext req, ResponseContext res) async {
var _path = path;
HttpClientResponse rs;
/// Proxies a request to the given path on the remote server.
Future<bool> servePath(
String path, RequestContext req, ResponseContext res) async {
http.StreamedResponse rs;
if (_prefix.isNotEmpty) {
_path = path.replaceAll(new RegExp('^' + _pathify(_prefix)), '');
}
// Create mapping
// _printDebug('Serving path $_path via proxy');
final mapping = '$mapTo/$_path'.replaceAll(_straySlashes, '');
// _printDebug('Mapped path $_path to path $mapping on proxy $host:$port');
final mapping = '$mapTo/$path'.replaceAll(_straySlashes, '');
try {
Future<HttpClientResponse> accessRemote() async {
var ips = await InternetAddress.lookup(host);
if (ips.isEmpty)
throw new StateError('Could not resolve remote host "$host".');
var address = ips.first.address;
final rq = await _client.open(req.method, address, port, mapping);
// _printDebug('Opened client request at "$address:$port/$mapping"');
Future<http.StreamedResponse> accessRemote() async {
var url = port == null ? host : '$host:$port';
url = url.replaceAll(_straySlashes, '');
url = '$url/$mapping';
copyHeaders(req.headers, rq.headers);
rq.headers.set(HttpHeaders.HOST, host);
// _printDebug('Copied headers');
rq.cookies.addAll(req.cookies ?? []);
// _printDebug('Added cookies');
rq.headers.set(
'X-Forwarded-For', req.io.connectionInfo.remoteAddress.address);
rq.headers
..set('X-Forwarded-Port', req.io.connectionInfo.remotePort.toString())
..set('X-Forwarded-Host',
req.headers.host ?? req.headers.value(HttpHeaders.HOST) ?? 'none')
..set('X-Forwarded-Proto', protocol);
// _printDebug('Added X-Forwarded headers');
if (!url.startsWith('http')) url = 'http://$url';
url = url.replaceAll(_straySlashes, '');
if (app.storeOriginalBuffer == true) {
var headers = <String, String>{
'host': port == null ? host : '$host:$port',
'x-forwarded-for': req.io.connectionInfo.remoteAddress.address,
'x-forwarded-port': req.io.connectionInfo.remotePort.toString(),
'x-forwarded-host':
req.headers.host ?? req.headers.value('host') ?? 'none',
'x-forwarded-proto': protocol,
};
req.headers.forEach((name, values) {
headers[name] = values.join(',');
});
headers['cookie'] =
req.cookies.map<String>((c) => '${c.name}=${c.value}').join('; ');
var body;
if (req.method != 'GET' && app.storeOriginalBuffer == true) {
await req.parse();
if (req.originalBuffer?.isNotEmpty == true)
rq.add(req.originalBuffer);
if (req.originalBuffer?.isNotEmpty == true) body = req.originalBuffer;
}
await rq.flush();
return await rq.close();
var rq = new http.Request(req.method, Uri.parse(url));
rq.headers.addAll(headers);
rq.headers['host'] = rq.url.host;
if (body != null) rq.bodyBytes = body;
return await httpClient.send(rq);
}
var future = accessRemote();
@ -146,11 +111,13 @@ class ProxyLayer {
if (recoverFromDead != false)
return true;
else
throw new AngelHttpException(e,
stackTrace: st,
statusCode: HttpStatus.GATEWAY_TIMEOUT,
message:
'Connection to remote host "$host" timed out after ${timeout.inMilliseconds}ms.');
throw new AngelHttpException(
e,
stackTrace: st,
statusCode: 504,
message:
'Connection to remote host "$host" timed out after ${timeout.inMilliseconds}ms.',
);
} catch (e) {
if (recoverFromDead != false)
return true;
@ -158,44 +125,21 @@ class ProxyLayer {
rethrow;
}
// _printDebug(
// 'Proxy responded to $mapping with status code ${rs.statusCode}');
if (rs.statusCode == 404 && recoverFrom404 != false) return true;
res
..statusCode = rs.statusCode
..contentType = rs.headers.contentType;
..headers.addAll(rs.headers);
// _printDebug('Proxy response headers:\n${rs.headers}');
if (rs.contentLength == 0 && recoverFromDead != false) return true;
if (streamToIO == true) {
res
..willCloseItself = true
..end();
var stream = rs.stream;
copyHeaders(rs.headers, res.io.headers);
res.io.statusCode = rs.statusCode;
if (rs.headers['content-encoding'] == 'gzip')
stream = stream.transform(GZIP.encoder);
if (rs.headers.contentType != null)
res.io.headers.contentType = rs.headers.contentType;
await stream.pipe(res);
// _printDebug('Outgoing content length: ${res.io.contentLength}');
if (rs.headers[HttpHeaders.CONTENT_ENCODING]?.contains('gzip') == true) {
res.io.headers.set(HttpHeaders.CONTENT_ENCODING, 'gzip');
await rs.transform(GZIP.encoder).pipe(res.io);
} else
await rs.pipe(res.io);
} else {
rs.headers.forEach((k, v) {
if (k != HttpHeaders.CONTENT_ENCODING || !v.contains('gzip'))
res.headers[k] = v.join(',');
});
await rs.forEach(res.buffer.add);
}
return res.buffer.isEmpty;
return false;
}
}

View file

@ -1,37 +0,0 @@
import 'package:angel_route/angel_route.dart';
import 'proxy_layer.dart';
class PubServeLayer extends ProxyLayer {
PubServeLayer(
{bool debug: false,
bool recoverFromDead: true,
bool recoverFrom404: true,
bool streamToIO: true,
String host: 'localhost',
String mapTo: '/',
int port: 8080,
String protocol: 'http',
String publicPath: '/',
ProxyLayerRouteAssigner routeAssigner,
Duration timeout})
: super(host, port,
debug: debug,
mapTo: mapTo,
protocol: protocol,
publicPath: publicPath,
recoverFromDead: recoverFromDead != false,
recoverFrom404: recoverFrom404 != false,
streamToIO: streamToIO != false,
routeAssigner: routeAssigner,
timeout: timeout);
@override
void serve(Router router) {
if (app?.isProduction == true) {
// Auto-deactivate in production ;)
return;
}
super.serve(router);
}
}

View file

@ -1,16 +1,14 @@
name: angel_proxy
description: Angel middleware to forward requests to another server (i.e. pub serve).
version: 1.0.9
version: 1.1.0-alpha
author: Tobe O <thosakwe@gmail.com>
homepage: https://github.com/angel-dart/proxy
environment:
sdk: ">=1.19.0"
dependencies:
angel_framework: ^1.0.0-dev
angel_framework: ^1.1.0-alpha
http: ^0.11.3
dev_dependencies:
angel_compress: ^1.0.0
angel_diagnostics: ^1.0.0
angel_test: ^1.0.0
http: ^0.11.3
test: ^0.12.15
angel_test: ^1.1.0-alpha
test: ^0.12.15

View file

@ -2,8 +2,8 @@ import 'dart:convert';
import 'dart:io';
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_proxy/angel_proxy.dart';
import 'package:angel_test/angel_test.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:test/test.dart';
import 'common.dart';
@ -15,18 +15,44 @@ main() {
setUp(() async {
app = new Angel()..storeOriginalBuffer = true;
var httpClient = new http.Client();
testServer = await testApp().startServer();
await app.configure(new ProxyLayer(
testServer.address.address, testServer.port,
publicPath: '/proxy',
routeAssigner: (router, path, handler) => router.all(path, handler)));
await app.configure(new ProxyLayer(
testServer.address.address, testServer.port,
mapTo: '/foo'));
var proxy1 = new Proxy(
app,
httpClient,
testServer.address.address,
port: testServer.port,
publicPath: '/proxy',
);
var proxy2 = new Proxy(
app,
httpClient,
testServer.address.address,
port: testServer.port,
mapTo: '/foo',
);
app.after.add((req, res) async => res.write('intercept empty'));
app.use(proxy1.handleRequest);
app.use(proxy2.handleRequest);
app.use((req, res) {
print('Intercepting empty from ${req.uri}');
res.write('intercept empty');
});
app.shutdownHooks.add((_) async {
httpClient.close();
});
app.logger = new Logger('angel');
Logger.root.onRecord.listen((rec) {
print(rec);
if (rec.error != null) print(rec.error);
if (rec.stackTrace != null) print(rec.stackTrace);
});
server = await app.startServer();
url = 'http://${server.address.address}:${server.port}';
@ -48,11 +74,6 @@ main() {
test('empty', () async {
var response = await client.get('$url/proxy/empty');
print('Response: ${response.body}');
// Shouldn't say it is gzipped...
expect(response, isNot(hasHeader('content-encoding')));
// Should have gzipped body
expect(response.body, 'intercept empty');
});
@ -64,10 +85,10 @@ main() {
test('original buffer', () async {
var response = await client.post('$url/proxy/body',
body: {'foo': 'bar'},
headers: {'content-type': 'application/x-www-form-urlencoded'});
body: JSON.encode({'foo': 'bar'}),
headers: {'content-type': 'application/json'});
print('Response: ${response.body}');
expect(response.body, isNotEmpty);
expect(JSON.decode(response.body), {'foo': 'bar'});
expect(response.body, isNot('intercept empty'));
});
}

View file

@ -1,16 +1,18 @@
import 'package:angel_framework/angel_framework.dart';
import 'package:logging/logging.dart';
Angel testApp() {
final app = new Angel();
final app = new Angel()..lazyParseBodies = true;
app.get('/hello', 'world');
app.get('/foo/bar', 'baz');
app.post('/body', (req, res) => req.lazyBody());
app.fatalErrorStream.listen((e) {
print('FATAL IN TEST APP: ${e.error}');
print(e.stack);
app.post('/body', (RequestContext req, res) async {
var body = await req.lazyBody();
print('Body: $body');
return body;
});
app.logger = new Logger('testApp');
return app..dumpTree();
}

View file

@ -1,9 +1,10 @@
import 'dart:convert';
import 'dart:io';
import 'package:angel_compress/angel_compress.dart';
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_proxy/angel_proxy.dart';
import 'package:angel_test/angel_test.dart';
import 'package:http/http.dart' as http;
import 'package:mock_request/mock_request.dart';
import 'package:test/test.dart';
main() {
@ -12,27 +13,40 @@ main() {
setUp(() async {
testApp = new Angel();
testApp.get('/foo', (req, res) => res.write('pub serve'));
testApp.get('/foo', (req, res) async {
res.write('pub serve');
});
testApp.get('/empty', (req, res) => res.end());
testApp.responseFinalizers.add(gzip());
testApp.responseFinalizers.add((req, ResponseContext res) async {
print('OUTGOING: ' + new String.fromCharCodes(res.buffer.toBytes()));
});
testApp.injectEncoders({'gzip': GZIP.encoder});
var server = await testApp.startServer();
app = new Angel();
app.get('/bar', (req, res) => res.write('normal'));
var layer = new PubServeLayer(
debug: true,
publicPath: '/proxy',
host: server.address.address,
port: server.port);
print('streamToIO: ${layer.streamToIO}');
await app.configure(layer);
var httpClient = new http.Client();
var layer = new Proxy(
app,
httpClient,
server.address.address,
port: server.port,
publicPath: '/proxy',
);
app.use(layer.handleRequest);
app.responseFinalizers.add((req, ResponseContext res) async {
print('Normal. Buf: ' +
new String.fromCharCodes(res.buffer.toBytes()) +
', headers: ${res.headers}');
});
app.responseFinalizers.add(gzip());
app.injectEncoders({'gzip': GZIP.encoder});
client = await connectTo(app);
});
@ -46,34 +60,19 @@ main() {
});
test('proxied', () async {
var response = await client.get('/proxy/foo');
// Should say it is gzipped...
expect(response, hasHeader('content-encoding', 'gzip'));
// Should have gzipped body
//
// We have to decode it, because `mock_request` does not auto-decode.
expect(response, hasBody('pub serve'));
var rq = new MockHttpRequest('GET', Uri.parse('/proxy/foo'))..close();
await app.handleRequest(rq);
var response = await rq.response.transform(UTF8.decoder).join();
expect(response, 'pub serve');
});
test('empty', () async {
var response = await client.get('/proxy/empty');
// Should say it is gzipped...
expect(response, hasHeader('content-encoding', 'gzip'));
// Should have gzipped body
expect(response.body, isEmpty);
});
test('normal', () async {
var response = await client.get('/bar');
// Should say it is gzipped...
expect(response, hasHeader('content-encoding', 'gzip'));
// Should have normal body
expect(response, hasBody('normal'));
});
}