From 393c4bff02bdc23e90717f1b71c6906b283942c1 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Sat, 23 Sep 2017 17:57:54 -0400 Subject: [PATCH] waiting on angel_test --- CHANGELOG.md | 5 + README.md | 114 ++----- .analysis-options => analysis_options.yaml | 0 lib/angel_static.dart | 3 - lib/src/cache.dart | 172 +++------- lib/src/file_info.dart | 94 ------ lib/src/file_transformer.dart | 15 - lib/src/serve_static.dart | 11 - lib/src/virtual_directory.dart | 373 +++------------------ pubspec.yaml | 10 +- test/all_test.dart | 62 ++-- test/cache_sample.dart | 25 +- test/cache_test.dart | 36 +- test/issue41_test.dart | 31 +- test/transformer_test.dart | 84 ----- 15 files changed, 200 insertions(+), 835 deletions(-) rename .analysis-options => analysis_options.yaml (100%) delete mode 100644 lib/src/file_info.dart delete mode 100644 lib/src/file_transformer.dart delete mode 100644 lib/src/serve_static.dart delete mode 100644 test/transformer_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 569e8fed..5a510dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 1.3.0-alpha +* Removed file transformers. +* `VirtualDirectory` is no longer an `AngelPlugin`, and instead exposes a `handleRequest` middleware. +* Added `pushState` to `VirtualDirectory`. + # 1.2.5 * Fixed a bug where `onlyInProduction` was not properly adhered to. * Fixed another bug where `Accept-Encoding` was not properly adhered to. diff --git a/README.md b/README.md index 4edda4a9..a551d3e5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ # static - [![Pub](https://img.shields.io/pub/v/angel_static.svg)](https://pub.dartlang.org/packages/angel_static) [![build status](https://travis-ci.org/angel-dart/static.svg?branch=master)](https://travis-ci.org/angel-dart/static) @@ -10,12 +9,11 @@ In `pubspec.yaml`: ```yaml dependencies: - angel_static: ^1.2.0 + angel_static: ^1.3.0 ``` # Usage -To serve files from a directory, your app needs to have a -`VirtualDirectory` mounted on it. +To serve files from a directory, you need to create a `VirtualDirectory`. ```dart import 'dart:io'; @@ -23,27 +21,36 @@ import 'package:angel_framework/angel_framework.dart'; import 'package:angel_static/angel_static.dart'; main() async { - final app = new Angel(); + var app = new Angel(); // Normal static server - await app.configure(new VirtualDirectory(source: new Directory('./public'))); + var vDir = new VirtualDirectory(source: new Directory('./public')); // Send Cache-Control, ETag, etc. as well - await app.configure(new CachingVirtualDirectory(source: new Directory('./public'))); + var vDir = new CachingVirtualDirectory(source: new Directory('./public')); + // Mount the VirtualDirectory's request handler + app.use(vDir.handleRequest); + + // Start your server!!! await app.startServer(); } ``` -# Push State Example -```dart -var vDir = new VirtualDirectory(...); -var indexFile = new File.fromUri(vDir.source.uri.resolve('index.html')); +# Push State +`VirtualDirectory` also exposes a `pushState` method that returns a +request handler that serves the file at a given path as a fallback, unless +the user is requesting that file. This can be very useful for SPA's. -app.after.add((req, ResponseContext res) { - // Fallback to index.html on 404 - return res.sendFile(indexFile); -}); +```dart +// Create VirtualDirectory as well +var vDir = new CachingVirtualDirectory(...); + +// Mount it +app.use(vDir.handleRequest); + +// Fallback to index.html on 404 +app.use(vDir.pushState('index.html')); ``` # Options @@ -54,80 +61,5 @@ The `VirtualDirectory` API accepts a few named parameters: - **publicPath**: To serve index files, you need to specify the virtual path under which angel_static is serving your files. If you are not serving static files at the site root, please include this. -- **debug**: Print verbose debug output. - **callback**: Runs before sending a file to a client. Use this to set headers, etc. If it returns anything other than `null` or `true`, -then the callback's result will be sent to the user, instead of the file contents. -- **streamToIO**: If set to `true`, files will be streamed to `res.io`, instead of added to `res.buffer`.. Default is `false`. - -# Transformers -`angel_static` now supports *transformers*. Similarly to `pub serve`, or `package:build`, these -let you dynamically compile assets before sending them to users. For example, in development, you might -consider using transformers to compile CSS files, or to even replace `pub serve`. -Transformers are supported by `VirtualDirectory` and `CachingVirtualDirectory`. - -To create a transformer: -```dart -class MinifierTransformer extends FileTransformer { - /// Use this to declare outputs, and indicate if your transformer - /// will compile a file. - @override - FileInfo declareOutput(FileInfo file) { - // For example, we might only want to minify HTML files. - if (!file.extensions.endsWith('.min.html')) - return null; - else return file.changeExtension('.min.html'); - } - - /// Actually compile the asset here. - @override - FutureOr transform(FileInfo file) async { - return file - .changeExtension('.min.html') - .changeContent( - file.content - .transform(UTF8.decoder) - .transform(const LineSplitter() - .transform(UTF8.encoder)) - ); - } -} -``` - -To use it: -```dart -configureServer(Angel app) async { - var vDir = new CachingVirtualDirectory( - transformers: [new MinifierTransformer()] - ); - await app.configure(vDir); - - // It is suggested that you await `transformersLoaded`. - // Otherwise, you may receive 404's on paths that should send a compiled asset. - await vDir.transformersLoaded; -} -``` - -## Pre-building -You can pre-build all your assets with one command: - -```dart -configureServer(Angel app) async { - var vDir = new VirtualDirectory(transformers: [...]); - await app.configure(vDir); - - // Build if in production - if (app.isProduction) { - await vDir.buildToDisk(); - } -} -``` - -## In Production -By default, transformers are disabled in production mode. -To force-enable them: - -```dart -configureServer(Angel app) async { - var vDir = new VirtualDirectory(useTransformersInProduction: true, transformers: [...]); -} -``` \ No newline at end of file +then the callback's result will be sent to the user, instead of the file contents. \ No newline at end of file diff --git a/.analysis-options b/analysis_options.yaml similarity index 100% rename from .analysis-options rename to analysis_options.yaml diff --git a/lib/angel_static.dart b/lib/angel_static.dart index 624906cd..4ad551a1 100644 --- a/lib/angel_static.dart +++ b/lib/angel_static.dart @@ -1,7 +1,4 @@ library angel_static; export 'src/cache.dart'; -export 'src/file_info.dart'; -export 'src/file_transformer.dart'; -export 'src/serve_static.dart'; export 'src/virtual_directory.dart'; diff --git a/lib/src/cache.dart b/lib/src/cache.dart index f0187e19..8a06cda7 100644 --- a/lib/src/cache.dart +++ b/lib/src/cache.dart @@ -1,12 +1,11 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; +import 'dart:typed_data'; import 'package:angel_framework/angel_framework.dart'; -import 'package:crypto/crypto.dart'; +import 'package:async/async.dart'; +import 'package:file/file.dart'; import 'package:intl/intl.dart'; import 'package:mime/mime.dart'; -import 'file_info.dart'; -import 'file_transformer.dart'; import 'virtual_directory.dart'; final DateFormat _fmt = new DateFormat('EEE, d MMM yyyy HH:mm:ss'); @@ -14,15 +13,9 @@ final DateFormat _fmt = new DateFormat('EEE, d MMM yyyy HH:mm:ss'); /// Formats a date (converted to UTC), ex: `Sun, 03 May 2015 23:02:37 GMT`. String formatDateForHttp(DateTime dt) => _fmt.format(dt.toUtc()) + ' GMT'; -/// Generates an ETag from the given buffer. -String generateEtag(List buf, {bool weak: true, Hash hash}) { - if (weak == false) { - Hash h = hash ?? md5; - return new String.fromCharCodes(h.convert(buf).bytes); - } else { - // length + first 50 bytes as base64url - return 'W/${buf.length}' + BASE64URL.encode(buf.take(50).toList()); - } +/// Generates a weak ETag from the given buffer. +String weakEtag(List buf) { + return 'W/${buf.length}' + BASE64URL.encode(buf); } /// Returns a string representation of the given [CacheAccessLevel]. @@ -37,18 +30,13 @@ String accessLevelToString(CacheAccessLevel accessLevel) { } } -/// A static server plug-in that also sets `Cache-Control` headers. +/// A `VirtualDirectory` that also sets `Cache-Control` headers. class CachingVirtualDirectory extends VirtualDirectory { final Map _etags = {}; /// Either `PUBLIC` or `PRIVATE`. final CacheAccessLevel accessLevel; - /// Used to generate strong ETags, if [useWeakEtags] is false. - /// - /// Default: `md5`. - final Hash hash; - /// If `true`, responses will always have `private, max-age=0` as their `Cache-Control` header. final bool noCache; @@ -58,37 +46,27 @@ class CachingVirtualDirectory extends VirtualDirectory { /// If `true` (default), ETags will be computed and sent along with responses. final bool useEtags; - /// If `false` (default: `true`), ETags will be generated via MD5 hash. - final bool useWeakEtags; - /// The `max-age` for `Cache-Control`. /// /// Set this to `null` to leave no `Expires` header on responses. final int maxAge; - CachingVirtualDirectory( + CachingVirtualDirectory(Angel app, FileSystem fileSystem, {this.accessLevel: CacheAccessLevel.PUBLIC, Directory source, bool debug, - this.hash, Iterable indexFileNames, this.maxAge: 0, this.noCache: false, this.onlyInProduction: false, this.useEtags: true, - this.useWeakEtags: true, String publicPath, - StaticFileCallback callback, - bool streamToIO: false, - Iterable transformers: const []}) - : super( + callback(File file, RequestContext req, ResponseContext res)}) + : super(app, fileSystem, source: source, - debug: debug == true, indexFileNames: indexFileNames ?? ['index.html'], publicPath: publicPath ?? '/', - callback: callback, - streamToIO: streamToIO == true, - transformers: transformers ?? []); + callback: callback); @override Future serveFile( @@ -100,17 +78,16 @@ class CachingVirtualDirectory extends VirtualDirectory { bool shouldNotCache = noCache == true; if (!shouldNotCache) { - shouldNotCache = - req.headers.value(HttpHeaders.CACHE_CONTROL) == 'no-cache' || - req.headers.value(HttpHeaders.PRAGMA) == 'no-cache'; + shouldNotCache = req.headers.value('cache-control') == 'no-cache' || + req.headers.value('pragma') == 'no-cache'; } if (shouldNotCache) { - res.headers[HttpHeaders.CACHE_CONTROL] = 'private, max-age=0, no-cache'; + res.headers['cache-control'] = 'private, max-age=0, no-cache'; return super.serveFile(file, stat, req, res); } else { if (useEtags == true) { - var etags = req.headers[HttpHeaders.IF_NONE_MATCH]; + var etags = req.headers['if-none-match']; if (etags?.isNotEmpty == true) { bool hasBeenModified = false; @@ -125,7 +102,7 @@ class CachingVirtualDirectory extends VirtualDirectory { } if (hasBeenModified) { - res.statusCode = HttpStatus.NOT_MODIFIED; + res.statusCode = 304; setCachedHeaders(stat.modified, req, res); return new Future.value(false); } @@ -137,11 +114,11 @@ class CachingVirtualDirectory extends VirtualDirectory { var ifModifiedSince = req.headers.ifModifiedSince; if (ifModifiedSince.compareTo(stat.modified) >= 0) { - res.statusCode = HttpStatus.NOT_MODIFIED; + res.statusCode = 304; setCachedHeaders(stat.modified, req, res); if (_etags.containsKey(file.absolute.path)) - res.headers[HttpHeaders.ETAG] = _etags[file.absolute.path]; + res.headers['ETag'] = _etags[file.absolute.path]; return new Future.value(false); } @@ -151,25 +128,38 @@ class CachingVirtualDirectory extends VirtualDirectory { } } - return file.readAsBytes().then((buf) { - var etag = _etags[file.absolute.path] = - generateEtag(buf, weak: useWeakEtags != false, hash: hash); + var queue = new StreamQueue(file.openRead()); + + return new Future(() async { + var buf = new Uint8List(50), hanging = []; + int added = 0; + + while (added < 50) { + var deficit = 50 - added; + var next = await queue.next; + + for (int i = 0; i < deficit; i++) { + buf[added + i] = next[i]; + } + + if (next.length > deficit) { + hanging.addAll(next.skip(deficit)); + } + } + + var etag = _etags[file.absolute.path] = weakEtag(buf); + + res.statusCode = 200; res.headers - ..[HttpHeaders.ETAG] = etag - ..[HttpHeaders.CONTENT_TYPE] = + ..['ETag'] = etag + ..['content-type'] = lookupMimeType(file.path) ?? 'application/octet-stream'; setCachedHeaders(stat.modified, req, res); - if (useWeakEtags == false) { - res - ..statusCode = 200 - ..willCloseItself = false - ..buffer.add(buf) - ..end(); - return new Future.value(false); - } + res.add(buf); + res.add(hanging); - return super.serveFile(file, stat, req, res); + return queue.rest.pipe(res).then((_) => false); }); } } @@ -179,80 +169,14 @@ class CachingVirtualDirectory extends VirtualDirectory { var privacy = accessLevelToString(accessLevel ?? CacheAccessLevel.PUBLIC); res.headers - ..[HttpHeaders.CACHE_CONTROL] = '$privacy, max-age=${maxAge ?? 0}' - ..[HttpHeaders.LAST_MODIFIED] = formatDateForHttp(modified); + ..['cache-control'] = '$privacy, max-age=${maxAge ?? 0}' + ..['last-modified'] = formatDateForHttp(modified); if (maxAge != null) { var expiry = new DateTime.now().add(new Duration(seconds: maxAge ?? 0)); - res.headers[HttpHeaders.EXPIRES] = formatDateForHttp(expiry); + res.headers['expires'] = formatDateForHttp(expiry); } } - - @override - Future serveAsset( - FileInfo fileInfo, RequestContext req, ResponseContext res) { - if (onlyInProduction == true && req.app.isProduction != true) { - return super.serveAsset(fileInfo, req, res); - } - - bool shouldNotCache = noCache == true; - - if (!shouldNotCache) { - shouldNotCache = - req.headers.value(HttpHeaders.CACHE_CONTROL) == 'no-cache' || - req.headers.value(HttpHeaders.PRAGMA) == 'no-cache'; - } - - if (shouldNotCache) { - res.headers[HttpHeaders.CACHE_CONTROL] = 'private, max-age=0, no-cache'; - return super.serveAsset(fileInfo, req, res); - } else { - if (useEtags == true) { - var etags = req.headers[HttpHeaders.IF_NONE_MATCH]; - - if (etags?.isNotEmpty == true) { - bool hasBeenModified = false; - - for (var etag in etags) { - if (etag == '*') - hasBeenModified = true; - else { - hasBeenModified = _etags.containsKey(fileInfo.filename) && - _etags[fileInfo.filename] == etag; - } - } - - if (hasBeenModified) { - res.statusCode = HttpStatus.NOT_MODIFIED; - setCachedHeaders(fileInfo.lastModified, req, res); - return new Future.value(false); - } - } - } - } - - if (req.headers.ifModifiedSince != null) { - try { - var ifModifiedSince = req.headers.ifModifiedSince; - - if (fileInfo.lastModified != null && - ifModifiedSince.compareTo(fileInfo.lastModified) >= 0) { - res.statusCode = HttpStatus.NOT_MODIFIED; - setCachedHeaders(fileInfo.lastModified, req, res); - - if (_etags.containsKey(fileInfo.filename)) - res.headers[HttpHeaders.ETAG] = _etags[fileInfo.filename]; - - return new Future.value(false); - } - } catch (_) { - throw new AngelHttpException.badRequest( - message: 'Invalid date for If-Modified-Since header.'); - } - } - - return super.serveAsset(fileInfo, req, res); - } } enum CacheAccessLevel { PUBLIC, PRIVATE } diff --git a/lib/src/file_info.dart b/lib/src/file_info.dart deleted file mode 100644 index 9405907c..00000000 --- a/lib/src/file_info.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:mime/mime.dart'; -import 'package:path/path.dart' as p; - -/// Represents information about a file, regardless of whether it exists in the filesystem -/// or in memory. -abstract class FileInfo { - /// Returns the content of the file. - Stream> get content; - - /// This file's extension. - String get extension; - - /// The name of the file. - String get filename; - - /// The time when this file was last modified. - DateTime get lastModified; - - /// The file's MIME type. - String get mimeType; - - /// Creates a [FileInfo] instance representing a physical file. - factory FileInfo.fromFile(File file) => new _FileInfoImpl( - () => file.openRead(), - file.absolute.path, - lookupMimeType(file.path) ?? 'application/octet-stream' ?? 'application/octet-stream', - file.statSync().modified); - - /// Creates a [FileInfo] describing a file that might not even exists to begin with. - factory FileInfo.hypothetical(String hypotheticalFileName) => - new _FileInfoImpl(null, hypotheticalFileName, - lookupMimeType(hypotheticalFileName) ?? 'application/octet-stream', null); - - /// Returns an identical instance, but with a different filename. - FileInfo changeFilename(String newFilename); - - /// Returns an identical instance, but with a different extension. - FileInfo changeExtension(String newExtension); - - /// Returns an identical instance, but with a different content. - FileInfo changeContent(Stream> newContent); - - /// Returns an identical instance, but with differnet content, set to the given String. - FileInfo changeText(String newText, {Encoding encoding: UTF8}); - - /// Returns an identical instance, but with a different MIME type. - FileInfo changeMimeType(String newMimeType); -} - -class _FileInfoImpl implements FileInfo { - @override - Stream> get content => getContent(); - - @override - final String filename, mimeType; - - @override - final DateTime lastModified; - - final Function getContent; - - _FileInfoImpl(Stream> this.getContent(), this.filename, - this.mimeType, this.lastModified); - - @override - String get extension => p.extension(filename); - - @override - FileInfo changeFilename(String newFilename) => new _FileInfoImpl( - getContent, - newFilename, - lookupMimeType(newFilename) ?? mimeType ?? 'application/octet-stream', - lastModified); - - @override - FileInfo changeExtension(String newExtension) => - changeFilename(p.withoutExtension(filename) + newExtension); - - @override - FileInfo changeContent(Stream> newContent) => - new _FileInfoImpl(() => newContent, filename, mimeType, lastModified); - - @override - FileInfo changeText(String newText, {Encoding encoding: UTF8}) => - changeContent(new Stream>.fromIterable( - [(encoding ?? UTF8).encode(newText)])); - - @override - FileInfo changeMimeType(String newMimeType) => - new _FileInfoImpl(getContent, filename, newMimeType, lastModified); -} diff --git a/lib/src/file_transformer.dart b/lib/src/file_transformer.dart deleted file mode 100644 index 21be2f85..00000000 --- a/lib/src/file_transformer.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'dart:async'; -import 'file_info.dart'; - -/// A class capable of transforming inputs into new outputs, on-the-fly. -/// -/// Ex. A transformer that compiles Stylus files. -abstract class FileTransformer { - /// Changes the name of a [file] into what it will be once it is transformed. - /// - /// If this transformer will not be consume the file, then return `null`. - FileInfo declareOutput(FileInfo file); - - /// Transforms an input [file] into a new representation. - FutureOr transform(FileInfo file); -} \ No newline at end of file diff --git a/lib/src/serve_static.dart b/lib/src/serve_static.dart deleted file mode 100644 index 53cd6698..00000000 --- a/lib/src/serve_static.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'dart:io'; -import 'package:angel_framework/angel_framework.dart'; - -@deprecated -RequestMiddleware serveStatic( - {Directory sourceDirectory, - List indexFileNames: const ['index.html'], - String virtualRoot: '/'}) { - throw new Exception( - 'The `serveStatic` API is now deprecated. Please update your application to use the new `VirtualDirectory` API.'); -} diff --git a/lib/src/virtual_directory.dart b/lib/src/virtual_directory.dart index e26ecf0a..ae52c190 100644 --- a/lib/src/virtual_directory.dart +++ b/lib/src/virtual_directory.dart @@ -1,15 +1,7 @@ import 'dart:async'; -import 'dart:io'; import 'package:angel_framework/angel_framework.dart'; -import 'package:angel_route/angel_route.dart'; -import 'package:cli_util/cli_logging.dart' as cli; +import 'package:file/file.dart'; import 'package:mime/mime.dart'; -import 'package:pool/pool.dart'; -import 'package:watcher/watcher.dart'; -import 'file_info.dart'; -import 'file_transformer.dart'; - -typedef StaticFileCallback(File file, RequestContext req, ResponseContext res); final RegExp _param = new RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?'); final RegExp _straySlashes = new RegExp(r'(^/+)|(/+$)'); @@ -31,24 +23,18 @@ String _pathify(String path) { } /// A static server plug-in. -class VirtualDirectory implements AngelPlugin { - final bool debug; - Angel _app; +class VirtualDirectory { String _prefix; Directory _source; - final Completer> _transformerLoad = - new Completer>(); - final Map _transformerMap = {}; - Pool _transformerMapMutex; - final List _transformers = []; - List _transformersCache; - StreamSubscription _watch; /// The directory to serve files from. Directory get source => _source; /// An optional callback to run before serving files. - final StaticFileCallback callback; + final Function(File file, RequestContext req, ResponseContext res) callback; + + final Angel app; + final FileSystem fileSystem; /// Filenames to be resolved within directories as indices. final Iterable indexFileNames; @@ -56,125 +42,46 @@ class VirtualDirectory implements AngelPlugin { /// An optional public path to map requests to. final String publicPath; - /// If set to `true`, files will be streamed to `res.io`, instead of added to `res.buffer`. - final bool streamToIO; - - /// A collection of [FileTransformer] instances that will be used to dynamically compile assets, if any. **READ-ONLY**. - List get transformers => - _transformersCache ?? - (_transformersCache = - new List.unmodifiable(_transformers)); - - /// If `true` (default: `false`), then transformers will not be disabled in production. - final bool useTransformersInProduction; - - /// Completes when all [transformers] are loaded. - Future> get transformersLoaded { - if ((!_app.isProduction || useTransformersInProduction == true) && - !_transformerLoad.isCompleted) - return _transformerLoad.future; - else - return new Future.value(_transformerMap); - } - - VirtualDirectory( + VirtualDirectory(this.app, this.fileSystem, {Directory source, - this.debug: false, this.indexFileNames: const ['index.html'], this.publicPath: '/', - this.callback, - this.streamToIO: false, - this.useTransformersInProduction: false, - Iterable transformers: const []}) { + this.callback}) { _prefix = publicPath.replaceAll(_straySlashes, ''); - this._transformers.addAll(transformers ?? []); - if (source != null) { _source = source; } else { - String dirPath = Platform.environment['ANGEL_ENV'] == 'production' - ? './build/web' - : './web'; - _source = new Directory(dirPath); + String dirPath = app.isProduction ? './build/web' : './web'; + _source = fileSystem.directory(dirPath); } } - call(Angel app) async { - serve(_app = app); - app.justBeforeStop.add((_) => close()); + /// Responds to incoming HTTP requests. + Future handleRequest(RequestContext req, ResponseContext res) { + if (req.method != 'GET') return new Future.value(true); + var path = req.path.replaceAll(_straySlashes, ''); + + if (_prefix?.isNotEmpty == true && !path.startsWith(_prefix)) + return new Future.value(true); + + return servePath(path, req, res); } - void serve(Router router) { - // _printDebug('Source directory: ${source.absolute.path}'); - // _printDebug('Public path prefix: "$_prefix"'); - //router.get('$publicPath/*', - router.get('$_prefix/*', (RequestContext req, ResponseContext res) async { + /// A handler that serves the file at the given path, unless the user has requested that path. + RequestMiddleware pushState(String path) { + var vPath = path.replaceAll(_straySlashes, ''); + if (_prefix?.isNotEmpty == true) vPath = '$_prefix/$vPath'; + + return (RequestContext req, ResponseContext res) { var path = req.path.replaceAll(_straySlashes, ''); - return servePath(path, req, res); - }); - - if ((!_app.isProduction || useTransformersInProduction == true) && - _transformers.isNotEmpty) { - // Create mutex, and watch for file changes - _transformerMapMutex = new Pool(1); - _transformerMapMutex.request().then((resx) { - _buildTransformerMap().then((_) => resx.release()); - }); - } + if (path == vPath) return new Future.value(true); + return servePath(vPath, req, res); + }; } - close() async { - if (!_transformerLoad.isCompleted && _transformers.isNotEmpty) { - _transformerLoad.completeError(new StateError( - 'VirtualDirectory was closed before all transformers loaded.')); - } - - _transformerMapMutex?.close(); - _watch?.cancel(); - } - - Future _buildTransformerMap() async { - print('VirtualDirectory is loading transformers...'); - - await for (var entity in source.list(recursive: true)) { - if (entity is File) { - _applyTransformers(entity.absolute.uri.toFilePath()); - } - } - - print('VirtualDirectory finished loading transformers.'); - _transformerLoad.complete(_transformerMap); - - _watch = - new DirectoryWatcher(source.absolute.path).events.listen((e) async { - _transformerMapMutex.withResource(() { - _applyTransformers(e.path); - }); - }); - } - - void _applyTransformers(String originalAbsolutePath) { - FileInfo file = new FileInfo.fromFile(new File(originalAbsolutePath)); - FileInfo outFile = file; - var wasClaimed = false; - - do { - wasClaimed = false; - for (var transformer in _transformers) { - var claimed = transformer.declareOutput(outFile); - if (claimed != null) { - outFile = claimed; - wasClaimed = true; - } - } - } while (wasClaimed); - - var finalName = outFile.filename; - if (finalName?.isNotEmpty == true && outFile != file) - _transformerMap[finalName] = originalAbsolutePath; - } - - servePath(String path, RequestContext req, ResponseContext res) async { + /// Writes the file at the given virtual [path] to a response. + Future servePath( + String path, RequestContext req, ResponseContext res) async { if (_prefix.isNotEmpty) { // Only replace the *first* incidence // Resolve: https://github.com/angel-dart/angel/issues/41 @@ -185,63 +92,41 @@ class VirtualDirectory implements AngelPlugin { path = path.replaceAll(_straySlashes, ''); var absolute = source.absolute.uri.resolve(path).toFilePath(); - var stat = await FileStat.stat(absolute); + var stat = await fileSystem.stat(absolute); return await serveStat(absolute, stat, req, res); } + /// Writes the file at the path given by the [stat] to a response. Future serveStat(String absolute, FileStat stat, RequestContext req, ResponseContext res) async { if (stat.type == FileSystemEntityType.DIRECTORY) - return await serveDirectory(new Directory(absolute), stat, req, res); + return await serveDirectory( + fileSystem.directory(absolute), stat, req, res); else if (stat.type == FileSystemEntityType.FILE) - return await serveFile(new File(absolute), stat, req, res); + return await serveFile(fileSystem.file(absolute), stat, req, res); else if (stat.type == FileSystemEntityType.LINK) { - var link = new Link(absolute); + var link = fileSystem.link(absolute); return await servePath(await link.resolveSymbolicLinks(), req, res); - } else if (_transformerMapMutex != null) { - var resx = await _transformerMapMutex.request(); - if (!_transformerMap.containsKey(absolute)) return true; - var sourceFile = new File(_transformerMap[absolute]); - resx.release(); - if (!await sourceFile.exists()) - return true; - else { - return await serveAsset(new FileInfo.fromFile(sourceFile), req, res); - } } else return true; } + /// Serves the index file of a [directory], if it exists. Future serveDirectory(Directory directory, FileStat stat, RequestContext req, ResponseContext res) async { for (String indexFileName in indexFileNames) { final index = - new File.fromUri(directory.absolute.uri.resolve(indexFileName)); + fileSystem.file(directory.absolute.uri.resolve(indexFileName)); if (await index.exists()) { return await serveFile(index, stat, req, res); } - - // Try to compile an asset - if (_transformerMap.isNotEmpty && - _transformerMap.containsKey(index.absolute.path)) { - return await serveAsset( - new FileInfo.fromFile( - new File(_transformerMap[index.absolute.path])), - req, - res); - } } return true; } - bool _acceptsGzip(RequestContext req) { - var h = req.headers.value(HttpHeaders.ACCEPT_ENCODING)?.toLowerCase(); - return h?.contains('*') == true || h?.contains('gzip') == true; - } - void _ensureContentTypeAllowed(String mimeType, RequestContext req) { - var value = req.headers.value(HttpHeaders.ACCEPT); + var value = req.headers.value('accept'); bool acceptable = value == null || value?.isNotEmpty != true || (mimeType?.isNotEmpty == true && value?.contains(mimeType) == true) || @@ -250,14 +135,13 @@ class VirtualDirectory implements AngelPlugin { throw new AngelHttpException( new UnsupportedError( 'Client requested $value, but server wanted to send $mimeType.'), - statusCode: HttpStatus.NOT_ACCEPTABLE, + statusCode: 406, message: '406 Not Acceptable'); } + /// Writes the contents of a file to a response. Future serveFile( File file, FileStat stat, RequestContext req, ResponseContext res) async { - // _printDebug('Sending file ${file.absolute.path}...'); - // _printDebug('MIME type for ${file.path}: ${lookupMimeType(file.path) ?? 'application/octet-stream'}'); res.statusCode = 200; if (callback != null) { @@ -268,178 +152,9 @@ class VirtualDirectory implements AngelPlugin { var type = lookupMimeType(file.path) ?? 'application/octet-stream'; _ensureContentTypeAllowed(type, req); - res.headers[HttpHeaders.CONTENT_TYPE] = type; + res.headers['content-type'] = type; - if (streamToIO == true) { - res - ..io.headers.set(HttpHeaders.CONTENT_TYPE, - lookupMimeType(file.path) ?? 'application/octet-stream') - ..end() - ..willCloseItself = true; - - if (_acceptsGzip(req)) - res.io.headers.set(HttpHeaders.CONTENT_ENCODING, 'gzip'); - - Stream> stream = _acceptsGzip(req) - ? file.openRead().transform(GZIP.encoder) - : file.openRead(); - await stream.pipe(res.io); - } else { - if (_acceptsGzip(req)) { - res.io.headers - ..set(HttpHeaders.CONTENT_TYPE, - lookupMimeType(file.path) ?? 'application/octet-stream') - ..set(HttpHeaders.CONTENT_ENCODING, 'gzip'); - await file.openRead().transform(GZIP.encoder).forEach(res.buffer.add); - res.end(); - } else - await res.sendFile(file); - } + await file.openRead().pipe(res); return false; } - - Future serveAsset( - FileInfo fileInfo, RequestContext req, ResponseContext res) async { - var file = await compileAsset(fileInfo); - if (file == null) return true; - _ensureContentTypeAllowed(file.mimeType, req); - res.headers[HttpHeaders.CONTENT_TYPE] = file.mimeType; - res.statusCode = 200; - - if (streamToIO == true) { - res - ..statusCode = 200 - ..io.headers.set(HttpHeaders.CONTENT_TYPE, - lookupMimeType(file.filename) ?? 'application/octet-stream') - ..end() - ..willCloseItself = true; - - if (_acceptsGzip(req)) - res.io.headers.set(HttpHeaders.CONTENT_ENCODING, 'gzip'); - - Stream> stream = _acceptsGzip(req) - ? file.content.transform(GZIP.encoder) - : file.content; - await stream.pipe(res.io); - } else { - if (_acceptsGzip(req)) { - res.io.headers.set(HttpHeaders.CONTENT_ENCODING, 'gzip'); - await file.content.transform(GZIP.encoder).forEach(res.buffer.add); - } else - await file.content.forEach(res.buffer.add); - } - - return false; - } - - /// Applies all [_transformers] to an input [file], if any. - Future compileAsset(FileInfo file) async { - var iterations = 0; - FileInfo result = file; - bool wasTransformed = false; - - do { - wasTransformed = false; - String originalName = file.filename; - for (var transformer in _transformers) { - if (++iterations >= 100) { - print('VirtualDirectory has tried 100 times to compile ${file - .filename}. Perhaps one of your transformers is not changing the output file\'s extension.'); - throw new AngelHttpException(new StackOverflowError(), - statusCode: 500); - } else if (iterations < 100) iterations++; - var claimed = transformer.declareOutput(result); - if (claimed != null) { - result = await transformer.transform(result); - wasTransformed = true; - } - } - - // Don't re-compile infinitely... - if (result.filename == originalName) wasTransformed = false; - } while (wasTransformed); - - return result == file ? null : result; - } - - /// Builds assets to disk using [transformers]. - Future buildToDisk() async { - var l = new cli.Logger.standard(); - print('Building assets in "${source.absolute.path}"...'); - - await for (var entity in source.list(recursive: true)) { - if (entity is File) { - var p = l.progress('Building "${entity.absolute.path}"'); - - try { - var asset = new FileInfo.fromFile(entity); - var compiled = await compileAsset(asset); - if (compiled == null) - p.finish( - message: '"${entity.absolute - .path}" did not require compilation; skipping it.'); - else { - var outFile = new File(compiled.filename); - if (!await outFile.exists()) await outFile.create(recursive: true); - var sink = outFile.openWrite(); - await compiled.content.pipe(sink); - p.finish( - message: - 'Built "${entity.absolute.path}" to "${compiled.filename}".', - showTiming: true); - } - } on AngelHttpException { - // Ignore 500 - } catch (e, st) { - p.finish(message: 'Failed to build "${entity.absolute.path}".'); - stderr..writeln(e)..writeln(st); - } - } - } - - print('Build of assets in "${source.absolute.path}" complete.'); - } - - /// Deletes any pre-built assets. - Future cleanFromDisk() async { - var l = new cli.Logger.standard(); - print('Cleaning assets in "${source.absolute.path}"...'); - - await for (var entity in source.list(recursive: true)) { - if (entity is File) { - var p = l.progress('Checking "${entity.absolute.path}"'); - - try { - var asset = new FileInfo.fromFile(entity); - var compiled = await compileAsset(asset); - if (compiled == null) - p.finish( - message: '"${entity.absolute - .path}" did not require compilation; skipping it.'); - else { - var outFile = new File(compiled.filename); - if (await outFile.exists()) { - await outFile.delete(); - p.finish( - message: 'Deleted "${compiled - .filename}", which was the output of "${entity.absolute - .path}".', - showTiming: true); - } else { - p.finish( - message: - 'Output "${compiled.filename}" of "${entity.absolute.path}" does not exist.'); - } - } - } on AngelHttpException { - // Ignore 500 - } catch (e, st) { - p.finish(message: 'Failed to delete "${entity.absolute.path}".'); - stderr..writeln(e)..writeln(st); - } - } - } - - print('Purge of assets in "${source.absolute.path}" complete.'); - } } diff --git a/pubspec.yaml b/pubspec.yaml index 36ffcfbc..6852094f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,18 +4,14 @@ environment: sdk: ">=1.19.0" homepage: https://github.com/angel-dart/static author: Tobe O -version: 1.2.5 +version: 1.3.0-alpha dependencies: - angel_framework: ^1.0.0-dev - cli_util: ^0.1.1 - crypto: ^2.0.0 + angel_framework: ^1.1.0-alpha + file: ^2.0.0 intl: ">=0.0.0 <1.0.0" mime: ^0.9.3 path: ^1.4.2 - pool: ^1.0.0 - watcher: ^0.9.7 dev_dependencies: - angel_diagnostics: ^1.0.0 angel_test: ^1.0.0 http: ^0.11.3 mustache4dart: ^1.1.0 diff --git a/test/all_test.dart b/test/all_test.dart index b74b3de2..ad7f4fa7 100644 --- a/test/all_test.dart +++ b/test/all_test.dart @@ -1,38 +1,40 @@ -import 'dart:io'; -import 'package:angel_diagnostics/angel_diagnostics.dart'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_static/angel_static.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import 'package:http/http.dart' show Client; +import 'package:logging/logging.dart'; import 'package:test/test.dart'; main() { Angel app; - Directory testDir = new Directory('test'); + Directory testDir = const LocalFileSystem().directory('test'); String url; Client client = new Client(); setUp(() async { app = new Angel(); + app.logger = new Logger('angel')..onRecord.listen(print); - await app.configure(new VirtualDirectory( - debug: true, - source: testDir, - publicPath: '/virtual', - indexFileNames: ['index.txt'])); + app.use( + new VirtualDirectory(app, const LocalFileSystem(), + source: testDir, + publicPath: '/virtual', + indexFileNames: ['index.txt']).handleRequest, + ); - await app.configure(new VirtualDirectory( - debug: true, - source: testDir, - streamToIO: true, - indexFileNames: ['index.php', 'index.txt'])); + app.use( + new VirtualDirectory(app, const LocalFileSystem(), + source: testDir, + indexFileNames: ['index.php', 'index.txt']).handleRequest, + ); - app.after.add('Fallback'); + app.use('Fallback'); app.dumpTree(showMatchers: true); - await app.configure(logRequests()); - await app.startServer(InternetAddress.LOOPBACK_IP_V4, 0); - url = "http://${app.httpServer.address.host}:${app.httpServer.port}"; + var server = await app.startServer(); + url = "http://${server.address.host}:${server.port}"; }); tearDown(() async { @@ -42,13 +44,13 @@ main() { test('can serve files, with correct Content-Type', () async { var response = await client.get("$url/sample.txt"); expect(response.body, equals("Hello world")); - expect(response.headers[HttpHeaders.CONTENT_TYPE], contains("text/plain")); + expect(response.headers['content-type'], contains("text/plain")); }); test('can serve child directories', () async { var response = await client.get("$url/nested"); expect(response.body, equals("Bird")); - expect(response.headers[HttpHeaders.CONTENT_TYPE], contains("text/plain")); + expect(response.headers['content-type'], contains("text/plain")); }); test('non-existent files are skipped', () async { @@ -68,7 +70,7 @@ main() { test('chrome accept', () async { var response = await client.get("$url/virtual", headers: { - HttpHeaders.ACCEPT: + 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8' }); expect(response.body, equals("index!")); @@ -76,25 +78,25 @@ main() { test('can gzip: just gzip', () async { var response = await client - .get("$url/sample.txt", headers: {HttpHeaders.ACCEPT_ENCODING: 'gzip'}); + .get("$url/sample.txt", headers: {'accept-encoding': 'gzip'}); expect(response.body, equals("Hello world")); - expect(response.headers[HttpHeaders.CONTENT_TYPE], contains("text/plain")); - expect(response.headers[HttpHeaders.CONTENT_ENCODING], 'gzip'); + expect(response.headers['content-type'], contains("text/plain")); + expect(response.headers['content-encoding'], 'gzip'); }); test('can gzip: wildcard', () async { var response = await client - .get("$url/sample.txt", headers: {HttpHeaders.ACCEPT_ENCODING: 'foo, *'}); + .get("$url/sample.txt", headers: {'accept-encoding': 'foo, *'}); expect(response.body, equals("Hello world")); - expect(response.headers[HttpHeaders.CONTENT_TYPE], contains("text/plain")); - expect(response.headers[HttpHeaders.CONTENT_ENCODING], 'gzip'); + expect(response.headers['content-type'], contains("text/plain")); + expect(response.headers['content-encoding'], 'gzip'); }); test('can gzip: gzip and friends', () async { - var response = await client - .get("$url/sample.txt", headers: {HttpHeaders.ACCEPT_ENCODING: 'gzip, deflate, br'}); + var response = await client.get("$url/sample.txt", + headers: {'accept-encoding': 'gzip, deflate, br'}); expect(response.body, equals("Hello world")); - expect(response.headers[HttpHeaders.CONTENT_TYPE], contains("text/plain")); - expect(response.headers[HttpHeaders.CONTENT_ENCODING], 'gzip'); + expect(response.headers['content-type'], contains("text/plain")); + expect(response.headers['content-encoding'], 'gzip'); }); } diff --git a/test/cache_sample.dart b/test/cache_sample.dart index 03321670..6a6e0dc7 100644 --- a/test/cache_sample.dart +++ b/test/cache_sample.dart @@ -1,24 +1,25 @@ -import 'dart:io'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_static/angel_static.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; main() async { Angel app; - Directory testDir = new Directory('test'); - app = new Angel(debug: true); + Directory testDir = const LocalFileSystem().directory('test'); + app = new Angel(); - await app.configure(new CachingVirtualDirectory( - source: testDir, - maxAge: 350, - onlyInProduction: false, - // useWeakEtags: false, - //publicPath: '/virtual', - indexFileNames: ['index.txt'])); + app.use( + new CachingVirtualDirectory(app, const LocalFileSystem(), + source: testDir, + maxAge: 350, + onlyInProduction: false, + indexFileNames: ['index.txt']).handleRequest, + ); app.get('*', 'Fallback'); app.dumpTree(showMatchers: true); - await app.startServer(InternetAddress.LOOPBACK_IP_V4, 0); - print('Open at http://${app.httpServer.address.host}:${app.httpServer.port}'); + var server = await app.startServer(); + print('Open at http://${server.address.host}:${server.port}'); } diff --git a/test/cache_test.dart b/test/cache_test.dart index 83225fa0..49425bb8 100644 --- a/test/cache_test.dart +++ b/test/cache_test.dart @@ -1,30 +1,33 @@ -import 'dart:io'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_static/angel_static.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import 'package:http/http.dart' show Client; import 'package:matcher/matcher.dart'; import 'package:test/test.dart'; main() { Angel app; - Directory testDir = new Directory('test'); + Directory testDir = const LocalFileSystem().directory('test'); String url; Client client = new Client(); setUp(() async { - app = new Angel(debug: true); + app = new Angel(); - await app.configure(new CachingVirtualDirectory( - source: testDir, maxAge: 350, onlyInProduction: false, - //publicPath: '/virtual', - indexFileNames: ['index.txt'])); + app.use( + new CachingVirtualDirectory(app, const LocalFileSystem(), + source: testDir, maxAge: 350, onlyInProduction: false, + //publicPath: '/virtual', + indexFileNames: ['index.txt']).handleRequest, + ); app.get('*', 'Fallback'); app.dumpTree(showMatchers: true); - await app.startServer(InternetAddress.LOOPBACK_IP_V4, 0); - url = "http://${app.httpServer.address.host}:${app.httpServer.port}"; + var server = await app.startServer(); + url = "http://${server.address.host}:${server.port}"; }); tearDown(() async { @@ -40,19 +43,14 @@ main() { expect(response.statusCode, equals(200)); expect( - [ - HttpHeaders.ETAG, - HttpHeaders.CACHE_CONTROL, - HttpHeaders.EXPIRES, - HttpHeaders.LAST_MODIFIED - ], + ['ETag', 'cache-control', 'expires', 'last-modified'], everyElement(predicate( response.headers.containsKey, 'contained in response headers'))); }); test('if-modified-since', () async { var response = await client.get("$url", headers: { - HttpHeaders.IF_MODIFIED_SINCE: + 'if-modified-since': formatDateForHttp(new DateTime.now().add(new Duration(days: 365))) }); @@ -60,11 +58,7 @@ main() { expect(response.statusCode, equals(304)); expect( - [ - HttpHeaders.CACHE_CONTROL, - HttpHeaders.EXPIRES, - HttpHeaders.LAST_MODIFIED - ], + ['cache-control', 'expires', 'last-modified'], everyElement(predicate( response.headers.containsKey, 'contained in response headers'))); }); diff --git a/test/issue41_test.dart b/test/issue41_test.dart index 212918a3..5db45080 100644 --- a/test/issue41_test.dart +++ b/test/issue41_test.dart @@ -1,13 +1,13 @@ -import 'dart:async'; -import 'dart:io'; -import 'package:angel_diagnostics/angel_diagnostics.dart'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_static/angel_static.dart'; import 'package:angel_test/angel_test.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:logging/logging.dart'; import 'package:test/test.dart'; final Directory swaggerUiDistDir = - new Directory('test/node_modules/swagger-ui-dist'); + const LocalFileSystem().directory('test/node_modules/swagger-ui-dist'); main() async { TestClient client; @@ -15,19 +15,22 @@ main() async { setUp(() async { // Load file contents - swaggerUiCssContents = - await new File.fromUri(swaggerUiDistDir.uri.resolve('swagger-ui.css')) - .readAsString(); - swaggerTestJsContents = - await new File.fromUri(swaggerUiDistDir.uri.resolve('test.js')) - .readAsString(); + swaggerUiCssContents = await const LocalFileSystem() + .file(swaggerUiDistDir.uri.resolve('swagger-ui.css')) + .readAsString(); + swaggerTestJsContents = await const LocalFileSystem() + .file(swaggerUiDistDir.uri.resolve('test.js')) + .readAsString(); // Initialize app var app = new Angel(); - await Future.forEach([ - new VirtualDirectory(source: swaggerUiDistDir, publicPath: 'swagger/'), - logRequests() - ], app.configure); + app.logger = new Logger('angel')..onRecord.listen(print); + + app.use( + new VirtualDirectory(app, const LocalFileSystem(), + source: swaggerUiDistDir, publicPath: 'swagger/') + .handleRequest, + ); app.dumpTree(); client = await connectTo(app); diff --git a/test/transformer_test.dart b/test/transformer_test.dart deleted file mode 100644 index 495fdf64..00000000 --- a/test/transformer_test.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:angel_framework/angel_framework.dart'; -import 'package:angel_static/angel_static.dart'; -import 'package:angel_test/angel_test.dart'; -import 'package:mustache4dart/mustache4dart.dart' as ms; -import 'package:test/test.dart'; - -main() { - TestClient client, client2; - - setUp(() async { - var app = new Angel(); - var vDir = new CachingVirtualDirectory( - source: new Directory('test'), - transformers: [new ExtensionTransformer()]); - await app.configure(vDir); - await vDir.transformersLoaded.then((map) { - print('Loaded transformer map: $map'); - }); - client = await connectTo(app); - - var app2 = new Angel(); - var vDir2 = new CachingVirtualDirectory( - source: new Directory('test'), - transformers: [ - new MustacheTransformer({'foo': 'bar'}) - ]); - await app2.configure(vDir2); - await vDir2.transformersLoaded.then((map) { - print('Loaded transformer map2: $map'); - }); - client2 = await connectTo(app2); - }); - - tearDown(() => client.close().then((_) => client2.close())); - - test('foo', () async { - var response = await client.get('/index.ext'); - print('Response: ${response.body}'); - expect(response, hasBody('.txt')); - }); - - test('request twice in a row', () async { - var response = await client2.get('/foo.html'); - print('Response: ${response.body}'); - print('Response headers: ${response.headers}'); - expect(response, hasBody('

bar

')); - - var response2 = await client2.get('/foo.html'); - expect(response2, hasHeader(HttpHeaders.CONTENT_TYPE, ContentType.HTML.mimeType)); - print('Response2: ${response2.body}'); - expect(response2, hasBody('

bar

')); - }); -} - -class ExtensionTransformer implements FileTransformer { - @override - FileInfo declareOutput(FileInfo file) { - return file.extension == '.ext' ? null : file.changeExtension('.ext'); - } - - @override - FutureOr transform(FileInfo file) => - file.changeText(file.extension).changeExtension('.ext'); -} - -class MustacheTransformer implements FileTransformer { - final Map locals; - - MustacheTransformer(this.locals); - - @override - FileInfo declareOutput(FileInfo file) => - file.extension == '.mustache' ? file.changeExtension('.html') : null; - - @override - FutureOr transform(FileInfo file) async { - var template = await file.content.transform(UTF8.decoder).join(); - var compiled = ms.render(template, locals ?? {}); - return file.changeExtension('.html').changeText(compiled); - } -}