From cc759d5268f67b0a23e3f5eb9cf364735a13c10a Mon Sep 17 00:00:00 2001 From: thosakwe Date: Tue, 6 Jun 2017 08:07:59 -0400 Subject: [PATCH] First RC. --- .analysis-options | 2 + .gitignore | 44 ++++++ .idea/hot.iml | 20 +++ .idea/modules.xml | 8 + .idea/runConfigurations/server_dart.xml | 8 + .idea/vcs.xml | 6 + README.md | 80 +++++++++- example/basic/server.dart | 37 +++++ example/basic/src/foo.dart | 9 ++ lib/angel_hot.dart | 201 ++++++++++++++++++++++++ pubspec.yaml | 20 +++ 11 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 .analysis-options create mode 100644 .idea/hot.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/runConfigurations/server_dart.xml create mode 100644 .idea/vcs.xml create mode 100644 example/basic/server.dart create mode 100644 example/basic/src/foo.dart create mode 100644 lib/angel_hot.dart create mode 100644 pubspec.yaml diff --git a/.analysis-options b/.analysis-options new file mode 100644 index 00000000..518eb901 --- /dev/null +++ b/.analysis-options @@ -0,0 +1,2 @@ +analyzer: + strong-mode: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4d2a4d6d..99e7978e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,47 @@ pubspec.lock # Directory created by dartdoc # If you don't generate documentation locally you can remove this line. doc/api/ +### 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 +.idea/dictionaries + +# 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 diff --git a/.idea/hot.iml b/.idea/hot.iml new file mode 100644 index 00000000..466ce67a --- /dev/null +++ b/.idea/hot.iml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..81b04c42 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/server_dart.xml b/.idea/runConfigurations/server_dart.xml new file mode 100644 index 00000000..84a78f11 --- /dev/null +++ b/.idea/runConfigurations/server_dart.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..94a25f7f --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 60b6883b..856205e2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,80 @@ # hot -Supports hot reloading of Angel servers on file changes. +[![version 1.0.0-rc.1](https://img.shields.io/badge/pub-1.0.0--rc.1-brightgreen.svg)](https://pub.dartlang.org/packages/angel_rethink) + +Supports *hot reloading* of Angel servers on file changes. This is faster and +more reliable than merely reactively restarting a `Process`. + +This package only works with the [Angel framework](https://github.com/angel-dart/angel). + +# Installation +In your `pubspec.yaml`: + +```yaml +dependencies: + angel_framework: ^1.0.0 + angel_hot: ^1.0.0 +``` + +# Usage +This package is dependent on the Dart VM service, so you *must* run +Dart with the `--enable-vm-service` argument!!! + +Usage is fairly simple. Pass a function that creates an `Angel` server, along with a collection of paths +to watch, to the `HotReloader` constructor. The rest is history!!! + +The recommended pattern is to only use hot-reloading in your application entry point. Create your `Angel` instance +within a separate function, conventionally named `createServer`. Using this in production mode is pointless. + +You can watch: + * Files + * Directories + * Globs + * URI's + * `package:` URI's + +```dart +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_compress/angel_compress.dart'; +import 'package:angel_diagnostics/angel_diagnostics.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_hot/angel_hot.dart'; +import 'src/foo.dart'; + +main() async { + var hot = new HotReloader(createServer, [ + new Directory('config'), + new Directory('lib'), + new Directory('web'), + new Directory('src'), + 'bin/server.dart', + Uri.parse('some_file.dart'), + Uri.parse('package:angel_hot/angel_hot.dart') + ]); + + var server = await hot.startServer(InternetAddress.LOOPBACK_IP_V4, 3000); + print( + 'Hot server listening at http://${server.address.address}:${server.port}'); +} + +Future createServer() async { + var app = new Angel(); + + app.lazyParseBodies = true; + app.injectSerializer(JSON.encode); + + app.get('/', {'hello': 'hot world!'}); + + app.post('/foo/bar', (req, res) async { + var result = await someLengthyOperation(); + return {'status': result}; + }); + + app.after.add(() => throw new AngelHttpException.notFound()); + + app.responseFinalizers.add(gzip()); + await app.configure(logRequests()); + return app; +} +``` \ No newline at end of file diff --git a/example/basic/server.dart b/example/basic/server.dart new file mode 100644 index 00000000..3c550213 --- /dev/null +++ b/example/basic/server.dart @@ -0,0 +1,37 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_compress/angel_compress.dart'; +import 'package:angel_diagnostics/angel_diagnostics.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_hot/angel_hot.dart'; +import 'src/foo.dart'; + +main() async { + var hot = new HotReloader(createServer, [ + new Directory('src'), + 'server.dart', + Uri.parse('package:angel_hot/angel_hot.dart') + ]); + var server = await hot.startServer(InternetAddress.LOOPBACK_IP_V4, 3000); + print( + 'Hot server listening at http://${server.address.address}:${server.port}'); +} + +Future createServer() async { + // Max speed??? + var app = new Angel(); + + app.lazyParseBodies = true; + app.injectSerializer(JSON.encode); + + app.get('/', {'hello': 'hot world!'}); + app.get('/foo', new Foo(bar: 'baz')); + + app.after.add(() => throw new AngelHttpException.notFound()); + + app.responseFinalizers.add(gzip()); + //await app.configure(cacheResponses()); + await app.configure(logRequests()); + return app; +} diff --git a/example/basic/src/foo.dart b/example/basic/src/foo.dart new file mode 100644 index 00000000..7d584344 --- /dev/null +++ b/example/basic/src/foo.dart @@ -0,0 +1,9 @@ +class Foo { + final String bar; + + Foo({this.bar}); + + Map toJson() { + return {'bar': bar}; + } +} diff --git a/lib/angel_hot.dart b/lib/angel_hot.dart new file mode 100644 index 00000000..a5036a87 --- /dev/null +++ b/lib/angel_hot.dart @@ -0,0 +1,201 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:glob/glob.dart'; +import 'package:html_builder/elements.dart'; +import 'package:html_builder/html_builder.dart'; +import 'package:vm_service_client/vm_service_client.dart'; +import 'package:watcher/watcher.dart'; + +/// A typedef over a function that returns a fresh [Angel] instance, whether synchronously or asynchronously. +typedef FutureOr AngelGenerator(); + +class HotReloader { + VMServiceClient _client; + final List _paths = []; + final StringRenderer _renderer = new StringRenderer(pretty: false); + final Queue _requestQueue = new Queue(); + Angel _server; + Duration _timeout; + + /// Invoked to load a new instance of [Angel] on file changes. + final AngelGenerator generator; + + /// The maximum amount of time to queue incoming requests for if there is no [server] available. + /// + /// If the timeout expires, then the request will be immediately terminated with a `502 Bad Gateway` error. + /// Default: `5s` + Duration get timeout => _timeout; + + /// A URL pointing to the Dart VM service. + /// + /// Default: `ws://localhost:8181/ws`. + final String vmServiceUrl; + + /// Initializes a hot reloader that proxies the server created by [generator]. + /// + /// [paths] can contain [FileSystemEntity], [Uri], [String] and [Glob] only. + /// URI's can be `package:` URI's as well. + HotReloader(this.generator, Iterable paths, + {Duration timeout, this.vmServiceUrl: 'ws://localhost:8181/ws'}) { + _timeout = timeout ?? new Duration(seconds: 5); + _paths.addAll(paths ?? []); + } + + Future handleRequest(HttpRequest request) async { + if (_server != null) + return await _server.handleRequest(request); + else if (timeout == null) + _requestQueue.add(request); + else { + _requestQueue.add(request); + new Timer(timeout, () { + if (_requestQueue.remove(request)) { + // Send 502 response + var doc = html(lang: 'en', c: [ + head(c: [ + meta( + name: 'viewport', + content: 'width=device-width, initial-scale=1'), + title(c: [text('502 Bad Gateway')]) + ]), + body(c: [ + h1(c: [text('502 Bad Gateway')]), + i(c: [ + text('Request timed out after ${timeout.inMilliseconds}ms.') + ]) + ]) + ]); + + var response = request.response; + response.statusCode = HttpStatus.BAD_GATEWAY; + response.headers + ..contentType = ContentType.HTML + ..set(HttpHeaders.SERVER, 'angel_hot'); + + if (request.headers + .value(HttpHeaders.ACCEPT_ENCODING) + ?.toLowerCase() + ?.contains('gzip') == + true) { + response + ..headers.set(HttpHeaders.CONTENT_ENCODING, 'gzip') + ..add(GZIP.encode(UTF8.encode(_renderer.render(doc)))); + } else + response.write(_renderer.render(doc)); + response.close(); + } + }); + } + } + + Future _generateServer() async { + var s = await generator() as Angel; + await Future.forEach(s.justBeforeStart, s.configure); + s.optimizeForProduction(); + return s; + } + + /// Starts listening to requests and filesystem events. + Future startServer([address, int port]) async { + if (_paths?.isNotEmpty != true) + print( + 'WARNING: You have instantiated a HotReloader without providing any filesystem paths to watch.'); + + var s = _server = await _generateServer(); + while (!_requestQueue.isEmpty) + await s.handleRequest(_requestQueue.removeFirst()); + await _listenToFilesystem(); + + var server = await HttpServer.bind( + address ?? InternetAddress.LOOPBACK_IP_V4, port ?? 0); + server.listen(handleRequest); + return server; + } + + _listenToFilesystem() async { + for (var path in _paths) { + if (path is String) { + await _listenToStat(path); + } else if (path is Glob) { + await for (var entity in path.list()) { + await _listenToStat(entity.path); + } + } else if (path is FileSystemEntity) { + await _listenToStat(path.path); + } else if (path is Uri) { + if (path.scheme == 'package') { + var uri = await Isolate.resolvePackageUri(path); + await _listenToStat(uri.toFilePath()); + } else + await _listenToStat(path.toFilePath()); + } else { + throw new ArgumentError( + 'Hot reload paths must be a FileSystemEntity, a Uri, a String or a Glob. You provided: $path'); + } + } + } + + _listenToStat(String path) async { + _listen() async { + try { + var stat = await FileStat.stat(path); + if (stat.type == FileSystemEntityType.LINK) { + var lnk = new Link(path); + var p = await lnk.resolveSymbolicLinks(); + return await _listenToStat(p); + } else if (stat.type == FileSystemEntityType.FILE) { + var file = new File(path); + if (!await file.exists()) return null; + } else if (stat.type == FileSystemEntityType.DIRECTORY) { + var dir = new Directory(path); + if (!await dir.exists()) return null; + } else + return null; + + var watcher = new Watcher(path); + //await watcher.ready; + watcher.events.listen(_handleWatchEvent); + print('Listening for file changes at ${path}...'); + return true; + } catch (e) { + if (e is! FileSystemException) rethrow; + } + } + + var r = await _listen(); + + if (r == null) { + print( + 'WARNING: Unable to watch path "$path" from working directory "${Directory.current.path}". Please ensure that it exists.'); + } + } + + _handleWatchEvent(WatchEvent e) async { + print('${e.path} changed. Reloading server...'); + var old = _server; + _server = null; + Future.forEach(old.justBeforeStop, old.configure); + + _client ??= + new VMServiceClient.connect(vmServiceUrl ?? 'ws://localhost:8181/ws'); + var vm = await _client.getVM(); + var mainIsolate = vm.isolates.first; + var runnable = await mainIsolate.loadRunnable(); + var report = await runnable.reloadSources(); + + if (!report.status) { + stderr.writeln('Hot reload failed!!!'); + stderr.writeln(report.message); + exit(1); + } + + var s = await _generateServer(); + _server = s; + while (!_requestQueue.isEmpty) + await s.handleRequest(_requestQueue.removeFirst()); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..6173fead --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,20 @@ +name: angel_hot +description: Supports hot reloading of Angel servers on file changes. +version: 1.0.0-rc.1 +author: Tobe O +homepage: https://github.com/angel-dart/hot +environment: + sdk: ">=1.19.0" +dependencies: + angel_framework: ^1.0.0-dev + html_builder: ^1.0.0 + vm_service_client: + git: + url: git://github.com/BlackHC/vm_service_client.git + ref: reload_sources_poc +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 \ No newline at end of file