diff --git a/lib/src/generate/build.dart b/lib/src/generate/build.dart index 076a6aeb4..510de5c1b 100644 --- a/lib/src/generate/build.dart +++ b/lib/src/generate/build.dart @@ -5,15 +5,16 @@ import 'dart:async'; import 'dart:io'; import 'package:logging/logging.dart'; +import 'package:shelf/shelf.dart'; -import '../asset/cache.dart'; -import '../asset/file_based.dart'; import '../asset/reader.dart'; import '../asset/writer.dart'; import '../package_graph/package_graph.dart'; +import '../server/server.dart'; import 'build_impl.dart'; import 'build_result.dart'; import 'directory_watcher_factory.dart'; +import 'options.dart'; import 'phase.dart'; import 'watch_impl.dart'; @@ -43,15 +44,13 @@ Future build(List> phaseGroups, Level logLevel, onLog(LogRecord), Stream terminateEventStream}) async { - var logListener = _setupLogging(logLevel: logLevel, onLog: onLog); - packageGraph ??= new PackageGraph.forThisPackage(); - var cache = new AssetCache(); - reader ??= - new CachedAssetReader(cache, new FileBasedAssetReader(packageGraph)); - writer ??= - new CachedAssetWriter(cache, new FileBasedAssetWriter(packageGraph)); - - var buildImpl = new BuildImpl(reader, writer, packageGraph, phaseGroups); + var options = new BuildOptions( + packageGraph: packageGraph, + reader: reader, + writer: writer, + logLevel: logLevel, + onLog: onLog); + var buildImpl = new BuildImpl(options, phaseGroups); /// Run the build! var futureResult = buildImpl.runBuild(); @@ -64,7 +63,7 @@ Future build(List> phaseGroups, var result = await futureResult; listener.cancel(); - logListener.cancel(); + options.logListener.cancel(); return result; } @@ -89,28 +88,72 @@ Stream watch(List> phaseGroups, AssetWriter writer, Level logLevel, onLog(LogRecord), - Duration debounceDelay: const Duration(milliseconds: 250), + Duration debounceDelay, DirectoryWatcherFactory directoryWatcherFactory, Stream terminateEventStream}) { - // We never cancel this listener in watch mode, because we never exit unless - // forced to. - var logListener = _setupLogging(logLevel: logLevel, onLog: onLog); - packageGraph ??= new PackageGraph.forThisPackage(); - var cache = new AssetCache(); - reader ??= - new CachedAssetReader(cache, new FileBasedAssetReader(packageGraph)); - writer ??= - new CachedAssetWriter(cache, new FileBasedAssetWriter(packageGraph)); - directoryWatcherFactory ??= defaultDirectoryWatcherFactory; - var watchImpl = new WatchImpl(directoryWatcherFactory, debounceDelay, reader, - writer, packageGraph, phaseGroups); + var options = new BuildOptions( + packageGraph: packageGraph, + reader: reader, + writer: writer, + logLevel: logLevel, + onLog: onLog, + debounceDelay: debounceDelay, + directoryWatcherFactory: directoryWatcherFactory); + var watchImpl = new WatchImpl(options, phaseGroups); + + var resultStream = watchImpl.runWatch(); + + // Stop doing new builds when told to terminate. + _setupTerminateLogic(terminateEventStream, () async { + await watchImpl.terminate(); + options.logListener.cancel(); + }); + + return resultStream; +} + +/// Same as [watch], except it also provides a server. +/// +/// This server will block all requests if a build is current in process. +/// +/// By default a static server will be set up to serve [directory] at +/// [address]:[port], but instead a [requestHandler] may be provided for custom +/// behavior. +Stream serve(List> phaseGroups, + {PackageGraph packageGraph, + AssetReader reader, + AssetWriter writer, + Level logLevel, + onLog(LogRecord), + Duration debounceDelay, + DirectoryWatcherFactory directoryWatcherFactory, + Stream terminateEventStream, + String directory, + String address, + int port, + Handler requestHandler}) { + var options = new BuildOptions( + packageGraph: packageGraph, + reader: reader, + writer: writer, + logLevel: logLevel, + onLog: onLog, + debounceDelay: debounceDelay, + directoryWatcherFactory: directoryWatcherFactory, + directory: directory, + address: address, + port: port); + var watchImpl = new WatchImpl(options, phaseGroups); var resultStream = watchImpl.runWatch(); + var serverStarted = startServer(watchImpl, options); // Stop doing new builds when told to terminate. _setupTerminateLogic(terminateEventStream, () async { await watchImpl.terminate(); - logListener.cancel(); + await serverStarted; + await stopServer(); + options.logListener.cancel(); }); return resultStream; @@ -135,10 +178,3 @@ StreamSubscription _setupTerminateLogic( }); return terminateListener; } - -StreamSubscription _setupLogging({Level logLevel, onLog(LogRecord)}) { - logLevel ??= Level.INFO; - Logger.root.level = logLevel; - onLog ??= stdout.writeln; - return Logger.root.onRecord.listen(onLog); -} diff --git a/lib/src/generate/build_impl.dart b/lib/src/generate/build_impl.dart index 109359251..7bd1e82ab 100644 --- a/lib/src/generate/build_impl.dart +++ b/lib/src/generate/build_impl.dart @@ -25,6 +25,7 @@ import '../package_graph/package_graph.dart'; import 'build_result.dart'; import 'exceptions.dart'; import 'input_set.dart'; +import 'options.dart'; import 'phase.dart'; /// Class which manages running builds. @@ -42,7 +43,10 @@ class BuildImpl { bool _isFirstBuild = true; - BuildImpl(this._reader, this._writer, this._packageGraph, this._phaseGroups); + BuildImpl(BuildOptions options, this._phaseGroups) + : _reader = options.reader, + _writer = options.writer, + _packageGraph = options.packageGraph; /// Runs a build /// diff --git a/lib/src/generate/options.dart b/lib/src/generate/options.dart new file mode 100644 index 000000000..ccaab9485 --- /dev/null +++ b/lib/src/generate/options.dart @@ -0,0 +1,69 @@ +// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'dart:async'; +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_static/shelf_static.dart'; + +import '../asset/cache.dart'; +import '../asset/file_based.dart'; +import '../asset/reader.dart'; +import '../asset/writer.dart'; +import '../package_graph/package_graph.dart'; +import 'directory_watcher_factory.dart'; + +/// Manages setting up consistent defaults for all options and build modes. +class BuildOptions { + /// Build mode options. + StreamSubscription logListener; + PackageGraph packageGraph; + AssetReader reader; + AssetWriter writer; + + /// Watch mode options. + Duration debounceDelay; + DirectoryWatcherFactory directoryWatcherFactory; + + /// Server options. + int port; + String address; + String directory; + Handler requestHandler; + + BuildOptions( + {this.debounceDelay, + this.directoryWatcherFactory, + Level logLevel, + onLog(LogRecord), + this.packageGraph, + this.reader, + this.writer, + this.directory, + this.address, + this.port, + this.requestHandler}) { + /// Set up logging + logLevel ??= Level.INFO; + Logger.root.level = logLevel; + onLog ??= stdout.writeln; + logListener = Logger.root.onRecord.listen(onLog); + + /// Set up other defaults. + address ??= 'localhost'; + directory ??= '.'; + port ??= 8000; + requestHandler ??= createStaticHandler(directory, + defaultDocument: 'index.html', listDirectories: true); + debounceDelay ??= const Duration(milliseconds: 250); + packageGraph ??= new PackageGraph.forThisPackage(); + var cache = new AssetCache(); + reader ??= + new CachedAssetReader(cache, new FileBasedAssetReader(packageGraph)); + writer ??= + new CachedAssetWriter(cache, new FileBasedAssetWriter(packageGraph)); + directoryWatcherFactory ??= defaultDirectoryWatcherFactory; + } +} diff --git a/lib/src/generate/watch_impl.dart b/lib/src/generate/watch_impl.dart index a555bfe1c..e51ae9d40 100644 --- a/lib/src/generate/watch_impl.dart +++ b/lib/src/generate/watch_impl.dart @@ -8,7 +8,6 @@ import 'package:path/path.dart' as path; import 'package:watcher/watcher.dart'; import '../asset/id.dart'; -import '../asset/reader.dart'; import '../asset/writer.dart'; import '../asset_graph/graph.dart'; import '../asset_graph/node.dart'; @@ -16,6 +15,7 @@ import '../package_graph/package_graph.dart'; import 'build_impl.dart'; import 'build_result.dart'; import 'directory_watcher_factory.dart'; +import 'options.dart'; import 'phase.dart'; /// Watches all inputs for changes, and uses a [BuildImpl] to rerun builds as @@ -47,6 +47,7 @@ class WatchImpl { /// A future that completes when the current build is done. Future _currentBuild; + Future get currentBuild => _currentBuild; /// Whether or not another build is scheduled. bool _nextBuildScheduled; @@ -60,16 +61,12 @@ class WatchImpl { /// Whether we are in the process of terminating. bool _terminating = false; - WatchImpl( - this._directoryWatcherFactory, - this._debounceDelay, - AssetReader reader, - AssetWriter writer, - PackageGraph packageGraph, - List> phaseGroups) - : _packageGraph = packageGraph, - _writer = writer, - _buildImpl = new BuildImpl(reader, writer, packageGraph, phaseGroups); + WatchImpl(BuildOptions options, List> phaseGroups) + : _directoryWatcherFactory = options.directoryWatcherFactory, + _debounceDelay = options.debounceDelay, + _writer = options.writer, + _packageGraph = options.packageGraph, + _buildImpl = new BuildImpl(options, phaseGroups); /// Completes after the current build is done, and stops further builds from /// happening. diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart new file mode 100644 index 000000000..a25f56269 --- /dev/null +++ b/lib/src/server/server.dart @@ -0,0 +1,48 @@ +// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'dart:async'; +import 'dart:io'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:shelf_static/shelf_static.dart'; + +import '../generate/options.dart'; +import '../generate/watch_impl.dart'; + +/// The actual [HttpServer] in use. +Future _futureServer; + +/// Public for testing purposes only :(. This file is not directly exported +/// though so it is effectively package private. +Handler blockingHandler; + +/// Starts a server which blocks on any ongoing builds. +Future startServer(WatchImpl watchImpl, BuildOptions options) { + if (_futureServer != null) { + throw new StateError('Server already running.'); + } + + try { + blockingHandler = (Request request) async { + if (watchImpl.currentBuild != null) await watchImpl.currentBuild; + return options.requestHandler(request); + }; + _futureServer = serve(blockingHandler, options.address, options.port); + return _futureServer; + } catch (e, s) { + stderr.writeln('Error setting up server: $e\n\n$s'); + return new Future.value(null); + } +} + +Future stopServer() { + if (_futureServer == null) { + throw new StateError('Server not running.'); + } + return _futureServer.then((server) { + server.close(); + _futureServer = null; + blockingHandler = null; + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 6a9005689..d4e00ccdb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,8 @@ dependencies: logging: ^0.11.2 glob: ^1.1.0 path: ^1.1.0 + shelf: ^0.6.5 + shelf_static: ^0.2.3 source_maps: '>=0.9.4 <0.11.0' source_span: '>=1.0.0 <2.0.0' stack_trace: ^1.6.0 diff --git a/test/common/common.dart b/test/common/common.dart index 471b13faa..cb53d8fd3 100644 --- a/test/common/common.dart +++ b/test/common/common.dart @@ -13,6 +13,7 @@ import 'in_memory_writer.dart'; export 'assets.dart'; export 'copy_builder.dart'; +export 'fake_watcher.dart'; export 'file_combiner_builder.dart'; export 'in_memory_reader.dart'; export 'in_memory_writer.dart'; @@ -51,3 +52,17 @@ void checkOutputs(Map outputs, BuildResult result, reason: 'Unexpected outputs found `$remainingOutputIds`.'); } } + +Future nextResult(results) { + var done = new Completer(); + var startingLength = results.length; + () async { + while (results.length == startingLength) { + await wait(10); + } + expect(results.length, startingLength + 1, + reason: 'Got two build results but only expected one'); + done.complete(results.last); + }(); + return done.future; +} diff --git a/test/common/copy_builder.dart b/test/common/copy_builder.dart index 63b8be660..2671dd386 100644 --- a/test/common/copy_builder.dart +++ b/test/common/copy_builder.dart @@ -20,13 +20,20 @@ class CopyBuilder implements Builder { /// asset. final AssetId copyFromAsset; + /// No `build` step will complete until this future completes. It may be + /// re-assigned in between builds. + Future blockUntil; + CopyBuilder( {this.numCopies: 1, this.extension: 'copy', this.outputPackage, - this.copyFromAsset}); + this.copyFromAsset, + this.blockUntil}); Future build(BuildStep buildStep) async { + if (blockUntil != null) await blockUntil; + var ids = declareOutputs(buildStep.input.id); for (var id in ids) { var content = copyFromAsset == null diff --git a/test/common/fake_watcher.dart b/test/common/fake_watcher.dart new file mode 100644 index 000000000..a52c83101 --- /dev/null +++ b/test/common/fake_watcher.dart @@ -0,0 +1,39 @@ +// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'dart:async'; + +import 'package:watcher/watcher.dart'; + +/// A fake [DirectoryWatcher]. +/// +/// Use the static [FakeWatcher#notifyPath] method to add events. +class FakeWatcher implements DirectoryWatcher { + String get directory => path; + final String path; + + FakeWatcher(this.path) { + watchers.add(this); + } + + final _eventsController = new StreamController(); + Stream get events => _eventsController.stream; + + Future get ready => new Future(() {}); + + bool get isReady => true; + + /// All watchers. + static final watchers = []; + + /// Notify all active watchers of [event] if their [FakeWatcher#path] matches. + /// The path will also be adjusted to remove the path. + static notifyWatchers(WatchEvent event) { + for (var watcher in watchers) { + if (event.path.startsWith(watcher.path)) { + watcher._eventsController.add(new WatchEvent( + event.type, event.path.replaceFirst(watcher.path, ''))); + } + } + } +} diff --git a/test/generate/serve_test.dart b/test/generate/serve_test.dart new file mode 100644 index 000000000..a9051927a --- /dev/null +++ b/test/generate/serve_test.dart @@ -0,0 +1,140 @@ +// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'dart:async'; +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; + +import 'package:build/build.dart'; +import 'package:build/src/server/server.dart' as server; +import 'package:shelf/shelf.dart'; +import 'package:watcher/watcher.dart'; + +import '../common/common.dart'; + +main() { + group('serve', () { + InMemoryAssetWriter writer; + CopyBuilder copyBuilder; + Phase copyAPhase; + List> copyAPhaseGroup; + + setUp(() { + _terminateServeController = new StreamController(); + writer = new InMemoryAssetWriter(); + + /// Basic phases/phase groups which get used in many tests + copyBuilder = new CopyBuilder(); + copyAPhase = new Phase([copyBuilder], [new InputSet('a')]); + copyAPhaseGroup = [ + [copyAPhase] + ]; + }); + + tearDown(() async { + FakeWatcher.watchers.clear(); + await terminateServe(); + }); + + test('does basic builds', () async { + var results = []; + startServe(copyAPhaseGroup, {'a|web/a.txt': 'a'}, writer) + .listen(results.add); + var result = await nextResult(results); + checkOutputs({'a|web/a.txt.copy': 'a'}, result, writer.assets); + + await writer.writeAsString(makeAsset('a|web/a.txt', 'b')); + FakeWatcher + .notifyWatchers(new WatchEvent(ChangeType.MODIFY, 'a/web/a.txt')); + + result = await nextResult(results); + checkOutputs({'a|web/a.txt.copy': 'b',}, result, writer.assets); + }); + + test('blocks serving files until the build is done', () async { + var buildBlocker1 = new Completer(); + copyBuilder.blockUntil = buildBlocker1.future; + + var results = []; + startServe(copyAPhaseGroup, {'a|web/a.txt': 'a'}, writer) + .listen(results.add); + // Give the build enough time to get started. + await wait(100); + + var request = + new Request('GET', Uri.parse('http://localhost:8000/CHANGELOG.md')); + server.blockingHandler(request).then((Response response) { + expect(buildBlocker1.isCompleted, isTrue, + reason: 'Server shouldn\'t respond until builds are done.'); + }); + await wait(250); + buildBlocker1.complete(); + var result = await nextResult(results); + checkOutputs({'a|web/a.txt.copy': 'a'}, result, writer.assets); + + /// Next request completes right away. + var buildBlocker2 = new Completer(); + server.blockingHandler(request).then((response) { + expect(buildBlocker1.isCompleted, isTrue); + expect(buildBlocker2.isCompleted, isFalse); + }); + + /// Make an edit to force another build, and we should block again. + copyBuilder.blockUntil = buildBlocker2.future; + await writer.writeAsString(makeAsset('a|web/a.txt', 'b')); + FakeWatcher + .notifyWatchers(new WatchEvent(ChangeType.MODIFY, 'a/web/a.txt')); + // Give the build enough time to get started. + await wait(500); + var done = new Completer(); + server.blockingHandler(request).then((response) { + expect(buildBlocker1.isCompleted, isTrue); + expect(buildBlocker2.isCompleted, isTrue); + done.complete(); + }); + await wait(250); + buildBlocker2.complete(); + result = await nextResult(results); + checkOutputs({'a|web/a.txt.copy': 'b',}, result, writer.assets); + + /// Make sure we actually see the final request finish. + return done.future; + }); + }); +} + +final _debounceDelay = new Duration(milliseconds: 10); +StreamController _terminateServeController; + +/// Start serving files and running builds. +Stream startServe(List> phases, + Map inputs, InMemoryAssetWriter writer) { + inputs.forEach((serializedId, contents) { + writer.writeAsString(makeAsset(serializedId, contents)); + }); + final actualAssets = writer.assets; + final reader = new InMemoryAssetReader(actualAssets); + final rootPackage = new PackageNode('a', null, null, new Uri.file('a/')); + final packageGraph = new PackageGraph.fromRoot(rootPackage); + final watcherFactory = (path) => new FakeWatcher(path); + + return serve(phases, + debounceDelay: _debounceDelay, + directoryWatcherFactory: watcherFactory, + reader: reader, + writer: writer, + packageGraph: packageGraph, + terminateEventStream: _terminateServeController.stream, + logLevel: Level.OFF); +} + +/// Tells the program to terminate. +Future terminateServe() { + assert(_terminateServeController != null); + + /// Can add any type of event. + _terminateServeController.add(null); + return _terminateServeController.close(); +} diff --git a/test/generate/watch_test.dart b/test/generate/watch_test.dart index d9ee092e4..2fcedea1e 100644 --- a/test/generate/watch_test.dart +++ b/test/generate/watch_test.dart @@ -13,8 +13,6 @@ import 'package:build/src/asset_graph/graph.dart'; import '../common/common.dart'; -final _watchers = []; - main() { /// Basic phases/phase groups which get used in many tests final copyAPhase = new Phase([new CopyBuilder()], [new InputSet('a')]); @@ -28,7 +26,7 @@ main() { }); tearDown(() { - _watchers.clear(); + FakeWatcher.watchers.clear(); return terminateWatch(); }); @@ -353,50 +351,3 @@ Future terminateWatch() { _terminateWatchController.add(null); return _terminateWatchController.close(); } - -/// A fake [DirectoryWatcher]. -/// -/// Use the static [FakeWatcher#notifyPath] method to add events. -class FakeWatcher implements DirectoryWatcher { - String get directory => path; - final String path; - - FakeWatcher(this.path) { - watchers.add(this); - } - - final _eventsController = new StreamController(); - Stream get events => _eventsController.stream; - - Future get ready => new Future(() {}); - - bool get isReady => true; - - /// All watchers. - static final watchers = []; - - /// Notify all active watchers of [event] if their [FakeWatcher#path] matches. - /// The path will also be adjusted to remove the path. - static notifyWatchers(WatchEvent event) { - for (var watcher in watchers) { - if (event.path.startsWith(watcher.path)) { - watcher._eventsController.add(new WatchEvent( - event.type, event.path.replaceFirst(watcher.path, ''))); - } - } - } -} - -Future nextResult(results) { - var done = new Completer(); - var startingLength = results.length; - () async { - while (results.length == startingLength) { - await wait(10); - } - expect(results.length, startingLength + 1, - reason: 'Got two build results but only expected one'); - done.complete(results.last); - }(); - return done.future; -}