Add 'packages/static/' from commit '6ec67dde7bd318c5e82fce66e3b22dfa3a378c24'
git-subtree-dir: packages/static git-subtree-mainline:17ad5f82c3
git-subtree-split:6ec67dde7b
This commit is contained in:
commit
f7c6ebf200
24 changed files with 1156 additions and 0 deletions
68
packages/static/.gitignore
vendored
Normal file
68
packages/static/.gitignore
vendored
Normal file
|
@ -0,0 +1,68 @@
|
|||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### JetBrains template
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio
|
||||
|
||||
*.iml
|
||||
|
||||
## Directory-based project format:
|
||||
.idea/
|
||||
# if you remove the above rule, at least ignore the following:
|
||||
|
||||
# User-specific stuff:
|
||||
# .idea/workspace.xml
|
||||
# .idea/tasks.xml
|
||||
# .idea/dictionaries
|
||||
|
||||
# Sensitive or high-churn files:
|
||||
# .idea/dataSources.ids
|
||||
# .idea/dataSources.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:
|
||||
*.ipr
|
||||
*.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
|
||||
### Dart template
|
||||
# Don’t commit the following directories created by pub.
|
||||
.buildlog
|
||||
.pub/
|
||||
build/
|
||||
packages
|
||||
.packages
|
||||
|
||||
# Or the files created by dart2js.
|
||||
*.dart.js
|
||||
*.js_
|
||||
*.js.deps
|
||||
*.js.map
|
||||
|
||||
# Include when developing application packages.
|
||||
pubspec.lock
|
||||
|
||||
.dart_tool
|
||||
*.mp3
|
||||
*.mp4
|
4
packages/static/.travis.yml
Normal file
4
packages/static/.travis.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
language: dart
|
||||
dart:
|
||||
- dev
|
||||
- stable
|
74
packages/static/CHANGELOG.md
Normal file
74
packages/static/CHANGELOG.md
Normal file
|
@ -0,0 +1,74 @@
|
|||
# 2.1.3+2
|
||||
* Prepare for upcoming change to File.openRead()
|
||||
|
||||
# 2.1.3+1
|
||||
* Apply control flow lints.
|
||||
|
||||
# 2.1.3
|
||||
* Apply lints.
|
||||
* Pin to Dart `>=2.0.0 <3.0.0`.
|
||||
* Use at least version `2.0.0-rc.0` of `angel_framework`.
|
||||
|
||||
# 2.1.2+1
|
||||
* Fix a typo that prevented `Range` requests from working.
|
||||
|
||||
# 2.1.2
|
||||
* Patch support for range+streaming in Caching server.
|
||||
|
||||
# 2.1.1
|
||||
* URI-encode paths in directory listing. This produces correct URL's, always.
|
||||
|
||||
# 2.1.0
|
||||
* Include support for the `Range` header.
|
||||
* Use MD5 for etags, instead of a weak ETag.
|
||||
|
||||
# 2.0.2
|
||||
* Fixed invalid HTML for directory listings.
|
||||
|
||||
# 2.0.1
|
||||
* Remove use of `sendFile`.
|
||||
* Add a `p.isWithin` check to ensure that paths do not escape the `source` directory.
|
||||
* Handle `HEAD` requests.
|
||||
|
||||
# 2.0.0
|
||||
* Upgrade dependencies to Angel 2 + file@5.
|
||||
* Replace `useStream` with `useBuffer`.
|
||||
* Remove `package:intl`, just use `HttpDate` instead.
|
||||
|
||||
# 1.3.0+1
|
||||
* Dart 2 fixes.
|
||||
* Enable optionally writing responses to the buffer instead of streaming.
|
||||
|
||||
# 1.3.0
|
||||
* `pushState` uses `strict` mode when `accepts` is passed.
|
||||
|
||||
# 1.3.0-alpha+2
|
||||
* Added an `accepts` option to `pushState`.
|
||||
* Added optional directory listings.
|
||||
|
||||
# 1.3.0-alpha+1
|
||||
* ETags once again only encode the first 50 bytes of files. Resolves [#27](https://github.com/angel-dart/static/issues/27).
|
||||
|
||||
# 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.
|
||||
* Setting `maxAge` to `null` will now prevent a `CachingVirtualDirectory` from sending an `Expires` header.
|
||||
* Pre-built assets can now be mass-deleted with `VirtualDirectory.cleanFromDisk()`.
|
||||
Resolves [#22](https://github.com/angel-dart/static/issues/22).
|
||||
|
||||
# 1.2.4+1
|
||||
Fixed a bug where `Accept-Encoding` was not properly adhered to.
|
||||
|
||||
# 1.2.4
|
||||
Fixes https://github.com/angel-dart/angel/issues/44.
|
||||
* MIME types will now default to `application/octet-stream`.
|
||||
* When `streamToIO` is `true`, the body will only be sent gzipped if the request explicitly allows it.
|
||||
|
||||
# 1.2.3
|
||||
Fixed #40 and #41, which dealt with paths being improperly served when using a
|
||||
`publicPath`.
|
21
packages/static/LICENSE
Normal file
21
packages/static/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 angel-dart
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
70
packages/static/README.md
Normal file
70
packages/static/README.md
Normal file
|
@ -0,0 +1,70 @@
|
|||
# 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)
|
||||
|
||||
Static server infrastructure for Angel.
|
||||
|
||||
*Can also handle `Range` requests now, making it suitable for media streaming, ex. music, video, etc.*
|
||||
|
||||
# Installation
|
||||
In `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
angel_static: ^2.0.0-alpha
|
||||
```
|
||||
|
||||
# Usage
|
||||
To serve files from a directory, you need to create a `VirtualDirectory`.
|
||||
Keep in mind that `angel_static` uses `package:file` instead of `dart:io`.
|
||||
|
||||
```dart
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_framework/http.dart';
|
||||
import 'package:angel_static/angel_static.dart';
|
||||
import 'package:file/local.dart';
|
||||
|
||||
main() async {
|
||||
var app = Angel();
|
||||
var fs = const LocalFileSystem();
|
||||
|
||||
// Normal static server
|
||||
var vDir = VirtualDirectory(app, fs, source: Directory('./public'));
|
||||
|
||||
// Send Cache-Control, ETag, etc. as well
|
||||
var vDir = CachingVirtualDirectory(app, fs, source: Directory('./public'));
|
||||
|
||||
// Mount the VirtualDirectory's request handler
|
||||
app.fallback(vDir.handleRequest);
|
||||
|
||||
// Start your server!!!
|
||||
await AngelHttp(app).startServer();
|
||||
}
|
||||
```
|
||||
|
||||
# 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.
|
||||
|
||||
```dart
|
||||
// Create VirtualDirectory as well
|
||||
var vDir = CachingVirtualDirectory(...);
|
||||
|
||||
// Mount it
|
||||
app.fallback(vDir.handleRequest);
|
||||
|
||||
// Fallback to index.html on 404
|
||||
app.fallback(vDir.pushState('index.html'));
|
||||
```
|
||||
|
||||
# Options
|
||||
The `VirtualDirectory` API accepts a few named parameters:
|
||||
- **source**: A `Directory` containing the files to be served. If left null, then Angel will serve either from `web` (in development) or
|
||||
`build/web` (in production), depending on your `ANGEL_ENV`.
|
||||
- **indexFileNames**: A `List<String>` of filenames that should be served as index pages. Default is `['index.html']`.
|
||||
- **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.
|
||||
- **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.
|
8
packages/static/analysis_options.yaml
Normal file
8
packages/static/analysis_options.yaml
Normal file
|
@ -0,0 +1,8 @@
|
|||
include: package:pedantic/analysis_options.yaml
|
||||
analyzer:
|
||||
strong-mode:
|
||||
implicit-casts: false
|
||||
linter:
|
||||
rules:
|
||||
- unnecessary_const
|
||||
- unnecessary_new
|
40
packages/static/example/main.dart
Normal file
40
packages/static/example/main.dart
Normal file
|
@ -0,0 +1,40 @@
|
|||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_framework/http.dart';
|
||||
import 'package:angel_static/angel_static.dart';
|
||||
import 'package:file/local.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
main(List<String> args) async {
|
||||
var app = Angel();
|
||||
var http = AngelHttp(app);
|
||||
var fs = const LocalFileSystem();
|
||||
var vDir = CachingVirtualDirectory(
|
||||
app,
|
||||
fs,
|
||||
allowDirectoryListing: true,
|
||||
source: args.isEmpty ? fs.currentDirectory : fs.directory(args[0]),
|
||||
maxAge: const Duration(days: 24).inSeconds,
|
||||
);
|
||||
|
||||
app.mimeTypeResolver
|
||||
..addExtension('', 'text/plain')
|
||||
..addExtension('dart', 'text/dart')
|
||||
..addExtension('lock', 'text/plain')
|
||||
..addExtension('markdown', 'text/plain')
|
||||
..addExtension('md', 'text/plain')
|
||||
..addExtension('yaml', 'text/plain');
|
||||
|
||||
app.logger = Logger('example')
|
||||
..onRecord.listen((rec) {
|
||||
print(rec);
|
||||
if (rec.error != null) print(rec.error);
|
||||
if (rec.stackTrace != null) print(rec.stackTrace);
|
||||
});
|
||||
|
||||
app.fallback(vDir.handleRequest);
|
||||
app.fallback((req, res) => throw AngelHttpException.notFound());
|
||||
|
||||
var server = await http.startServer('127.0.0.1', 3000);
|
||||
print('Serving from ${vDir.source.path}');
|
||||
print('Listening at http://${server.address.address}:${server.port}');
|
||||
}
|
4
packages/static/lib/angel_static.dart
Normal file
4
packages/static/lib/angel_static.dart
Normal file
|
@ -0,0 +1,4 @@
|
|||
library angel_static;
|
||||
|
||||
export 'src/cache.dart';
|
||||
export 'src/virtual_directory.dart';
|
182
packages/static/lib/src/cache.dart
Normal file
182
packages/static/lib/src/cache.dart
Normal file
|
@ -0,0 +1,182 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io' show HttpDate;
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'virtual_directory.dart';
|
||||
|
||||
/// Returns a string representation of the given [CacheAccessLevel].
|
||||
String accessLevelToString(CacheAccessLevel accessLevel) {
|
||||
switch (accessLevel) {
|
||||
case CacheAccessLevel.PRIVATE:
|
||||
return 'private';
|
||||
case CacheAccessLevel.PUBLIC:
|
||||
return 'public';
|
||||
default:
|
||||
throw ArgumentError('Unrecognized cache access level: $accessLevel');
|
||||
}
|
||||
}
|
||||
|
||||
/// A `VirtualDirectory` that also sets `Cache-Control` headers.
|
||||
class CachingVirtualDirectory extends VirtualDirectory {
|
||||
final Map<String, String> _etags = {};
|
||||
|
||||
/// Either `PUBLIC` or `PRIVATE`.
|
||||
final CacheAccessLevel accessLevel;
|
||||
|
||||
/// If `true`, responses will always have `private, max-age=0` as their `Cache-Control` header.
|
||||
final bool noCache;
|
||||
|
||||
/// If `true` (default), `Cache-Control` headers will only be set if the application is in production mode.
|
||||
final bool onlyInProduction;
|
||||
|
||||
/// If `true` (default), ETags will be computed and sent along with responses.
|
||||
final bool useEtags;
|
||||
|
||||
/// The `max-age` for `Cache-Control`.
|
||||
///
|
||||
/// Set this to `null` to leave no `Expires` header on responses.
|
||||
final int maxAge;
|
||||
|
||||
CachingVirtualDirectory(Angel app, FileSystem fileSystem,
|
||||
{this.accessLevel = CacheAccessLevel.PUBLIC,
|
||||
Directory source,
|
||||
bool debug,
|
||||
Iterable<String> indexFileNames,
|
||||
this.maxAge = 0,
|
||||
this.noCache = false,
|
||||
this.onlyInProduction = false,
|
||||
this.useEtags = true,
|
||||
bool allowDirectoryListing,
|
||||
bool useBuffer = false,
|
||||
String publicPath,
|
||||
callback(File file, RequestContext req, ResponseContext res)})
|
||||
: super(app, fileSystem,
|
||||
source: source,
|
||||
indexFileNames: indexFileNames ?? ['index.html'],
|
||||
publicPath: publicPath ?? '/',
|
||||
callback: callback,
|
||||
allowDirectoryListing: allowDirectoryListing,
|
||||
useBuffer: useBuffer);
|
||||
|
||||
@override
|
||||
Future<bool> serveFile(
|
||||
File file, FileStat stat, RequestContext req, ResponseContext res) {
|
||||
res.headers['accept-ranges'] = 'bytes';
|
||||
|
||||
if (onlyInProduction == true && req.app.environment.isProduction != true) {
|
||||
return super.serveFile(file, stat, req, res);
|
||||
}
|
||||
|
||||
bool shouldNotCache = noCache == true;
|
||||
|
||||
if (!shouldNotCache) {
|
||||
shouldNotCache = req.headers.value('cache-control') == 'no-cache' ||
|
||||
req.headers.value('pragma') == 'no-cache';
|
||||
}
|
||||
|
||||
if (shouldNotCache) {
|
||||
res.headers['cache-control'] = 'private, max-age=0, no-cache';
|
||||
return super.serveFile(file, stat, req, res);
|
||||
} else {
|
||||
var ifModified = req.headers.ifModifiedSince;
|
||||
bool ifRange = false;
|
||||
|
||||
try {
|
||||
ifModified = HttpDate.parse(req.headers.value('if-range'));
|
||||
ifRange = true;
|
||||
} catch (_) {
|
||||
// Fail silently...
|
||||
}
|
||||
|
||||
if (ifModified != null) {
|
||||
try {
|
||||
var ifModifiedSince = ifModified;
|
||||
|
||||
if (ifModifiedSince.compareTo(stat.modified) >= 0) {
|
||||
res.statusCode = 304;
|
||||
setCachedHeaders(stat.modified, req, res);
|
||||
|
||||
if (useEtags && _etags.containsKey(file.absolute.path)) {
|
||||
res.headers['ETag'] = _etags[file.absolute.path];
|
||||
}
|
||||
|
||||
if (ifRange) {
|
||||
// Send the 206 like normal
|
||||
res.statusCode = 206;
|
||||
return super.serveFile(file, stat, req, res);
|
||||
}
|
||||
|
||||
return Future.value(false);
|
||||
} else if (ifRange) {
|
||||
return super.serveFile(file, stat, req, res);
|
||||
}
|
||||
} catch (_) {
|
||||
throw AngelHttpException.badRequest(
|
||||
message:
|
||||
'Invalid date for ${ifRange ? 'if-range' : 'if-not-modified-since'} header.');
|
||||
}
|
||||
}
|
||||
|
||||
// If-modified didn't work; try etags
|
||||
|
||||
if (useEtags == true) {
|
||||
var etagsToMatchAgainst = req.headers['if-none-match'];
|
||||
ifRange = false;
|
||||
|
||||
if (etagsToMatchAgainst?.isNotEmpty != true) {
|
||||
etagsToMatchAgainst = req.headers['if-range'];
|
||||
ifRange = etagsToMatchAgainst?.isNotEmpty == true;
|
||||
}
|
||||
|
||||
if (etagsToMatchAgainst?.isNotEmpty == true) {
|
||||
bool hasBeenModified = false;
|
||||
|
||||
for (var etag in etagsToMatchAgainst) {
|
||||
if (etag == '*') {
|
||||
hasBeenModified = true;
|
||||
} else {
|
||||
hasBeenModified = !_etags.containsKey(file.absolute.path) ||
|
||||
_etags[file.absolute.path] != etag;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ifRange) {
|
||||
if (!hasBeenModified) {
|
||||
res.statusCode = 304;
|
||||
setCachedHeaders(stat.modified, req, res);
|
||||
return Future.value(false);
|
||||
}
|
||||
} else {
|
||||
return super.serveFile(file, stat, req, res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return file.lastModified().then((stamp) {
|
||||
if (useEtags) {
|
||||
res.headers['ETag'] = _etags[file.absolute.path] =
|
||||
stamp.millisecondsSinceEpoch.toString();
|
||||
}
|
||||
|
||||
setCachedHeaders(stat.modified, req, res);
|
||||
return super.serveFile(file, stat, req, res);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void setCachedHeaders(
|
||||
DateTime modified, RequestContext req, ResponseContext res) {
|
||||
var privacy = accessLevelToString(accessLevel ?? CacheAccessLevel.PUBLIC);
|
||||
|
||||
res.headers
|
||||
..['cache-control'] = '$privacy, max-age=${maxAge ?? 0}'
|
||||
..['last-modified'] = HttpDate.format(modified);
|
||||
|
||||
if (maxAge != null) {
|
||||
var expiry = DateTime.now().add(Duration(seconds: maxAge ?? 0));
|
||||
res.headers['expires'] = HttpDate.format(expiry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CacheAccessLevel { PUBLIC, PRIVATE }
|
339
packages/static/lib/src/virtual_directory.dart
Normal file
339
packages/static/lib/src/virtual_directory.dart
Normal file
|
@ -0,0 +1,339 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:range_header/range_header.dart';
|
||||
|
||||
final RegExp _param = RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?');
|
||||
final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// A static server plug-in.
|
||||
class VirtualDirectory {
|
||||
String _prefix;
|
||||
Directory _source;
|
||||
|
||||
/// The directory to serve files from.
|
||||
Directory get source => _source;
|
||||
|
||||
/// An optional callback to run before serving files.
|
||||
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<String> indexFileNames;
|
||||
|
||||
/// An optional public path to map requests to.
|
||||
final String publicPath;
|
||||
|
||||
/// If `true` (default: `false`), then if a directory does not contain any of the specific [indexFileNames], a default directory listing will be served.
|
||||
final bool allowDirectoryListing;
|
||||
|
||||
/// If `true` (default: `true`), then files will be opened as streams and piped into the request.
|
||||
///
|
||||
/// If not, the response buffer will be used instead.
|
||||
final bool useBuffer;
|
||||
|
||||
VirtualDirectory(this.app, this.fileSystem,
|
||||
{Directory source,
|
||||
this.indexFileNames = const ['index.html'],
|
||||
this.publicPath = '/',
|
||||
this.callback,
|
||||
this.allowDirectoryListing = false,
|
||||
this.useBuffer = false}) {
|
||||
_prefix = publicPath.replaceAll(_straySlashes, '');
|
||||
if (source != null) {
|
||||
_source = source;
|
||||
} else {
|
||||
String dirPath = app.environment.isProduction ? './build/web' : './web';
|
||||
_source = fileSystem.directory(dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// Responds to incoming HTTP requests.
|
||||
Future<bool> handleRequest(RequestContext req, ResponseContext res) {
|
||||
if (req.method != 'GET' && req.method != 'HEAD') {
|
||||
return Future<bool>.value(true);
|
||||
}
|
||||
var path = req.uri.path.replaceAll(_straySlashes, '');
|
||||
|
||||
if (_prefix?.isNotEmpty == true && !path.startsWith(_prefix)) {
|
||||
return Future<bool>.value(true);
|
||||
}
|
||||
|
||||
return servePath(path, req, res);
|
||||
}
|
||||
|
||||
/// A handler that serves the file at the given path, unless the user has requested that path.
|
||||
///
|
||||
/// You can also limit this functionality to specific values of the `Accept` header, ex. `text/html`.
|
||||
/// If [accepts] is `null`, OR at least one of the content types in [accepts] is present,
|
||||
/// the view will be served.
|
||||
RequestHandler pushState(String path, {Iterable accepts}) {
|
||||
var vPath = path.replaceAll(_straySlashes, '');
|
||||
if (_prefix?.isNotEmpty == true) vPath = '$_prefix/$vPath';
|
||||
|
||||
return (RequestContext req, ResponseContext res) {
|
||||
var path = req.path.replaceAll(_straySlashes, '');
|
||||
if (path == vPath) return Future<bool>.value(true);
|
||||
|
||||
if (accepts?.isNotEmpty == true) {
|
||||
if (!accepts.any((x) => req.accepts(x, strict: true))) {
|
||||
return Future<bool>.value(true);
|
||||
}
|
||||
}
|
||||
|
||||
return servePath(vPath, req, res);
|
||||
};
|
||||
}
|
||||
|
||||
/// Writes the file at the given virtual [path] to a response.
|
||||
Future<bool> 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
|
||||
path = path.replaceFirst(RegExp('^' + _pathify(_prefix)), '');
|
||||
}
|
||||
|
||||
if (path.isEmpty) path = '.';
|
||||
path = path.replaceAll(_straySlashes, '');
|
||||
|
||||
var absolute = source.absolute.uri.resolve(path).toFilePath();
|
||||
var parent = source.absolute.uri.toFilePath();
|
||||
|
||||
if (!p.isWithin(parent, absolute) && !p.equals(parent, absolute)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var stat = await fileSystem.stat(absolute);
|
||||
return await serveStat(absolute, path, stat, req, res);
|
||||
}
|
||||
|
||||
/// Writes the file at the path given by the [stat] to a response.
|
||||
Future<bool> serveStat(String absolute, String relative, FileStat stat,
|
||||
RequestContext req, ResponseContext res) async {
|
||||
if (stat.type == FileSystemEntityType.directory) {
|
||||
return await serveDirectory(
|
||||
fileSystem.directory(absolute), relative, stat, req, res);
|
||||
} else if (stat.type == FileSystemEntityType.file) {
|
||||
return await serveFile(fileSystem.file(absolute), stat, req, res);
|
||||
} else if (stat.type == FileSystemEntityType.link) {
|
||||
var link = fileSystem.link(absolute);
|
||||
return await servePath(await link.resolveSymbolicLinks(), req, res);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves the index file of a [directory], if it exists.
|
||||
Future<bool> serveDirectory(Directory directory, String relative,
|
||||
FileStat stat, RequestContext req, ResponseContext res) async {
|
||||
for (String indexFileName in indexFileNames) {
|
||||
final index =
|
||||
fileSystem.file(directory.absolute.uri.resolve(indexFileName));
|
||||
if (await index.exists()) {
|
||||
return await serveFile(index, stat, req, res);
|
||||
}
|
||||
}
|
||||
|
||||
if (allowDirectoryListing == true) {
|
||||
res.contentType = MediaType('text', 'html');
|
||||
res
|
||||
..write('<!DOCTYPE html>')
|
||||
..write('<html>')
|
||||
..write(
|
||||
'<head><meta name="viewport" content="width=device-width,initial-scale=1">')
|
||||
..write('<style>ul { list-style-type: none; }</style>')
|
||||
..write('</head><body>');
|
||||
|
||||
res.write('<li><a href="..">..</a></li>');
|
||||
|
||||
List<FileSystemEntity> entities = await directory
|
||||
.list(followLinks: false)
|
||||
.toList()
|
||||
.then((l) => List.from(l));
|
||||
entities.sort((a, b) {
|
||||
if (a is Directory) {
|
||||
if (b is Directory) return a.path.compareTo(b.path);
|
||||
return -1;
|
||||
} else if (a is File) {
|
||||
if (b is Directory) {
|
||||
return 1;
|
||||
} else if (b is File) return a.path.compareTo(b.path);
|
||||
return -1;
|
||||
} else if (b is Link) return a.path.compareTo(b.path);
|
||||
|
||||
return 1;
|
||||
});
|
||||
|
||||
for (var entity in entities) {
|
||||
var stub = p.basename(entity.path);
|
||||
var href = stub;
|
||||
String type;
|
||||
|
||||
if (entity is File) {
|
||||
type = '[File]';
|
||||
} else if (entity is Directory) {
|
||||
type = '[Directory]';
|
||||
} else if (entity is Link) type = '[Link]';
|
||||
|
||||
if (relative.isNotEmpty) href = '/' + relative + '/' + stub;
|
||||
|
||||
if (entity is Directory) href += '/';
|
||||
href = Uri.encodeFull(href);
|
||||
|
||||
res.write('<li><a href="$href">$type $stub</a></li>');
|
||||
}
|
||||
|
||||
res..write('</body></html>');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void _ensureContentTypeAllowed(String mimeType, RequestContext req) {
|
||||
var value = req.headers.value('accept');
|
||||
bool acceptable = value == null ||
|
||||
value?.isNotEmpty != true ||
|
||||
(mimeType?.isNotEmpty == true && value?.contains(mimeType) == true) ||
|
||||
value?.contains('*/*') == true;
|
||||
if (!acceptable) {
|
||||
throw AngelHttpException(
|
||||
UnsupportedError(
|
||||
'Client requested $value, but server wanted to send $mimeType.'),
|
||||
statusCode: 406,
|
||||
message: '406 Not Acceptable');
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the contents of a file to a response.
|
||||
Future<bool> serveFile(
|
||||
File file, FileStat stat, RequestContext req, ResponseContext res) async {
|
||||
res.headers['accept-ranges'] = 'bytes';
|
||||
|
||||
if (callback != null) {
|
||||
return await req.app.executeHandler(
|
||||
(RequestContext req, ResponseContext res) => callback(file, req, res),
|
||||
req,
|
||||
res);
|
||||
}
|
||||
|
||||
var type =
|
||||
app.mimeTypeResolver.lookup(file.path) ?? 'application/octet-stream';
|
||||
res.headers['accept-ranges'] = 'bytes';
|
||||
_ensureContentTypeAllowed(type, req);
|
||||
res.headers['accept-ranges'] = 'bytes';
|
||||
res.contentType = MediaType.parse(type);
|
||||
if (useBuffer == true) res.useBuffer();
|
||||
|
||||
if (req.headers.value('range')?.startsWith('bytes=') != true) {
|
||||
await res.streamFile(file);
|
||||
} else {
|
||||
var header = RangeHeader.parse(req.headers.value('range'));
|
||||
var items = RangeHeader.foldItems(header.items);
|
||||
var totalFileSize = await file.length();
|
||||
header = RangeHeader(items);
|
||||
|
||||
for (var item in header.items) {
|
||||
bool invalid = false;
|
||||
|
||||
if (item.start != -1) {
|
||||
invalid = item.end != -1 && item.end < item.start;
|
||||
} else {
|
||||
invalid = item.end == -1;
|
||||
}
|
||||
|
||||
if (invalid) {
|
||||
throw AngelHttpException(
|
||||
Exception("Semantically invalid, or unbounded range."),
|
||||
statusCode: 416,
|
||||
message: "Semantically invalid, or unbounded range.");
|
||||
}
|
||||
|
||||
// Ensure it's within range.
|
||||
if (item.start >= totalFileSize || item.end >= totalFileSize) {
|
||||
throw AngelHttpException(
|
||||
Exception("Given range $item is out of bounds."),
|
||||
statusCode: 416,
|
||||
message: "Given range $item is out of bounds.");
|
||||
}
|
||||
}
|
||||
|
||||
if (header.items.isEmpty) {
|
||||
throw AngelHttpException(null,
|
||||
statusCode: 416, message: '`Range` header may not be empty.');
|
||||
} else if (header.items.length == 1) {
|
||||
var item = header.items[0];
|
||||
Stream<Uint8List> stream;
|
||||
int len = 0, total = totalFileSize;
|
||||
|
||||
if (item.start == -1) {
|
||||
if (item.end == -1) {
|
||||
len = total;
|
||||
stream = file.openRead();
|
||||
} else {
|
||||
len = item.end + 1;
|
||||
stream = file.openRead(0, item.end + 1);
|
||||
}
|
||||
} else {
|
||||
if (item.end == -1) {
|
||||
len = total - item.start;
|
||||
stream = file.openRead(item.start);
|
||||
} else {
|
||||
len = item.end - item.start + 1;
|
||||
stream = file.openRead(item.start, item.end + 1);
|
||||
}
|
||||
}
|
||||
|
||||
res.contentType = MediaType.parse(
|
||||
app.mimeTypeResolver.lookup(file.path) ??
|
||||
'application/octet-stream');
|
||||
res.statusCode = 206;
|
||||
res.headers['content-length'] = len.toString();
|
||||
res.headers['content-range'] = 'bytes ' + item.toContentRange(total);
|
||||
await stream.cast<List<int>>().pipe(res);
|
||||
return false;
|
||||
} else {
|
||||
var transformer = RangeHeaderTransformer(
|
||||
header,
|
||||
app.mimeTypeResolver.lookup(file.path) ??
|
||||
'application/octet-stream',
|
||||
await file.length());
|
||||
res.statusCode = 206;
|
||||
res.headers['content-length'] =
|
||||
transformer.computeContentLength(totalFileSize).toString();
|
||||
res.contentType = MediaType(
|
||||
'multipart', 'byteranges', {'boundary': transformer.boundary});
|
||||
await file
|
||||
.openRead()
|
||||
.cast<List<int>>()
|
||||
.transform(transformer)
|
||||
.pipe(res);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
22
packages/static/pubspec.yaml
Normal file
22
packages/static/pubspec.yaml
Normal file
|
@ -0,0 +1,22 @@
|
|||
name: angel_static
|
||||
description: Static server middleware for Angel. Also capable of serving Range responses.
|
||||
environment:
|
||||
sdk: ">=2.0.0 <3.0.0"
|
||||
homepage: https://github.com/angel-dart/static
|
||||
author: Tobe O <thosakwe@gmail.com>
|
||||
version: 2.1.3+2
|
||||
dependencies:
|
||||
angel_framework: ^2.0.0-rc.0
|
||||
convert: ^2.0.0
|
||||
crypto: ^2.0.0
|
||||
file: ^5.0.0
|
||||
http_parser: ^3.0.0
|
||||
path: ^1.4.2
|
||||
range_header: ^2.0.0
|
||||
dev_dependencies:
|
||||
angel_test: ^2.0.0-alpha
|
||||
http:
|
||||
logging: ^0.11.0
|
||||
matcher: ^0.12.0
|
||||
pedantic: ^1.0.0
|
||||
test: ^1.0.0
|
2
packages/static/test/HELLO.md
Normal file
2
packages/static/test/HELLO.md
Normal file
|
@ -0,0 +1,2 @@
|
|||
# hello
|
||||
world!
|
82
packages/static/test/all_test.dart
Normal file
82
packages/static/test/all_test.dart
Normal file
|
@ -0,0 +1,82 @@
|
|||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_framework/http.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;
|
||||
AngelHttp http;
|
||||
Directory testDir = const LocalFileSystem().directory('test');
|
||||
String url;
|
||||
Client client = Client();
|
||||
|
||||
setUp(() async {
|
||||
app = Angel();
|
||||
http = AngelHttp(app);
|
||||
app.logger = Logger('angel')..onRecord.listen(print);
|
||||
|
||||
app.fallback(
|
||||
VirtualDirectory(app, const LocalFileSystem(),
|
||||
source: testDir,
|
||||
publicPath: '/virtual',
|
||||
indexFileNames: ['index.txt']).handleRequest,
|
||||
);
|
||||
|
||||
app.fallback(
|
||||
VirtualDirectory(app, const LocalFileSystem(),
|
||||
source: testDir,
|
||||
useBuffer: true,
|
||||
indexFileNames: ['index.php', 'index.txt']).handleRequest,
|
||||
);
|
||||
|
||||
app.fallback((req, res) => 'Fallback');
|
||||
|
||||
app.dumpTree(showMatchers: true);
|
||||
|
||||
var server = await http.startServer();
|
||||
url = "http://${server.address.host}:${server.port}";
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (http.server != null) await http.server.close(force: true);
|
||||
});
|
||||
|
||||
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['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['content-type'], contains("text/plain"));
|
||||
});
|
||||
|
||||
test('non-existent files are skipped', () async {
|
||||
var response = await client.get("$url/nonexist.ent");
|
||||
expect(response.body, equals('"Fallback"'));
|
||||
});
|
||||
|
||||
test('can match index files', () async {
|
||||
var response = await client.get(url);
|
||||
expect(response.body, equals("index!"));
|
||||
});
|
||||
|
||||
test('virtualRoots can match index', () async {
|
||||
var response = await client.get("$url/virtual");
|
||||
expect(response.body, equals("index!"));
|
||||
});
|
||||
|
||||
test('chrome accept', () async {
|
||||
var response = await client.get("$url/virtual", headers: {
|
||||
'accept':
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
|
||||
});
|
||||
expect(response.body, equals("index!"));
|
||||
});
|
||||
}
|
28
packages/static/test/cache_sample.dart
Normal file
28
packages/static/test/cache_sample.dart
Normal file
|
@ -0,0 +1,28 @@
|
|||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_framework/http.dart';
|
||||
import 'package:angel_static/angel_static.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
|
||||
main() async {
|
||||
Angel app;
|
||||
AngelHttp http;
|
||||
Directory testDir = const LocalFileSystem().directory('test');
|
||||
app = Angel();
|
||||
http = AngelHttp(app);
|
||||
|
||||
app.fallback(
|
||||
CachingVirtualDirectory(app, const LocalFileSystem(),
|
||||
source: testDir,
|
||||
maxAge: 350,
|
||||
onlyInProduction: false,
|
||||
indexFileNames: ['index.txt']).handleRequest,
|
||||
);
|
||||
|
||||
app.get('*', (req, res) => 'Fallback');
|
||||
|
||||
app.dumpTree(showMatchers: true);
|
||||
|
||||
var server = await http.startServer();
|
||||
print('Open at http://${server.address.host}:${server.port}');
|
||||
}
|
77
packages/static/test/cache_test.dart
Normal file
77
packages/static/test/cache_test.dart
Normal file
|
@ -0,0 +1,77 @@
|
|||
import 'dart:io' show HttpDate;
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_framework/http.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:matcher/matcher.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
main() {
|
||||
Angel app;
|
||||
AngelHttp http;
|
||||
Directory testDir = const LocalFileSystem().directory('test');
|
||||
String url;
|
||||
Client client = Client();
|
||||
|
||||
setUp(() async {
|
||||
app = Angel();
|
||||
http = AngelHttp(app);
|
||||
|
||||
app.fallback(
|
||||
CachingVirtualDirectory(app, const LocalFileSystem(),
|
||||
source: testDir, maxAge: 350, onlyInProduction: false,
|
||||
//publicPath: '/virtual',
|
||||
indexFileNames: ['index.txt']).handleRequest,
|
||||
);
|
||||
|
||||
app.get('*', (req, res) => 'Fallback');
|
||||
|
||||
app.dumpTree(showMatchers: true);
|
||||
|
||||
app.logger = Logger('angel_static')
|
||||
..onRecord.listen((rec) {
|
||||
print(rec);
|
||||
if (rec.error != null) print(rec.error);
|
||||
if (rec.stackTrace != null) print(rec.stackTrace);
|
||||
});
|
||||
|
||||
var server = await http.startServer();
|
||||
url = "http://${server.address.host}:${server.port}";
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (http.server != null) await http.server.close(force: true);
|
||||
});
|
||||
|
||||
test('sets etag, cache-control, expires, last-modified', () async {
|
||||
var response = await client.get("$url");
|
||||
|
||||
print('Response status: ${response.statusCode}');
|
||||
print('Response body: ${response.body}');
|
||||
print('Response headers: ${response.headers}');
|
||||
|
||||
expect(response.statusCode, equals(200));
|
||||
expect(
|
||||
['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: {
|
||||
'if-modified-since':
|
||||
HttpDate.format(DateTime.now().add(Duration(days: 365)))
|
||||
});
|
||||
|
||||
print('Response status: ${response.statusCode}');
|
||||
|
||||
expect(response.statusCode, equals(304));
|
||||
expect(
|
||||
['cache-control', 'expires', 'last-modified'],
|
||||
everyElement(predicate(
|
||||
response.headers.containsKey, 'contained in response headers')));
|
||||
});
|
||||
}
|
1
packages/static/test/foo.mustache
Normal file
1
packages/static/test/foo.mustache
Normal file
|
@ -0,0 +1 @@
|
|||
<h1>{{foo}}</h1>
|
1
packages/static/test/index.txt
Normal file
1
packages/static/test/index.txt
Normal file
|
@ -0,0 +1 @@
|
|||
index!
|
64
packages/static/test/issue41_test.dart
Normal file
64
packages/static/test/issue41_test.dart
Normal file
|
@ -0,0 +1,64 @@
|
|||
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 =
|
||||
const LocalFileSystem().directory('test/node_modules/swagger-ui-dist');
|
||||
|
||||
main() async {
|
||||
TestClient client;
|
||||
String swaggerUiCssContents, swaggerTestJsContents;
|
||||
|
||||
setUp(() async {
|
||||
// Load file contents
|
||||
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 = Angel();
|
||||
app.logger = Logger('angel')..onRecord.listen(print);
|
||||
|
||||
app.fallback(
|
||||
VirtualDirectory(app, const LocalFileSystem(),
|
||||
source: swaggerUiDistDir, publicPath: 'swagger/')
|
||||
.handleRequest,
|
||||
);
|
||||
|
||||
app.dumpTree();
|
||||
client = await connectTo(app);
|
||||
});
|
||||
|
||||
tearDown(() => client.close());
|
||||
|
||||
test('prefix is not replaced in file paths', () async {
|
||||
var response = await client.get('/swagger/swagger-ui.css');
|
||||
print('Response: ${response.body}');
|
||||
expect(response, hasBody(swaggerUiCssContents));
|
||||
});
|
||||
|
||||
test('get a file without prefix in name', () async {
|
||||
var response = await client.get('/swagger/test.js');
|
||||
print('Response: ${response.body}');
|
||||
expect(response, hasBody(swaggerTestJsContents));
|
||||
});
|
||||
|
||||
test('trailing slash at root', () async {
|
||||
var response = await client.get('/swagger');
|
||||
var body1 = response.body;
|
||||
print('Response #1: $body1');
|
||||
|
||||
response = await client.get('/swagger/');
|
||||
var body2 = response.body;
|
||||
print('Response #2: $body2');
|
||||
|
||||
expect(body1, body2);
|
||||
});
|
||||
}
|
1
packages/static/test/nested/index.txt
Normal file
1
packages/static/test/nested/index.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Bird
|
10
packages/static/test/node_modules/swagger-ui-dist/index.html
generated
vendored
Normal file
10
packages/static/test/node_modules/swagger-ui-dist/index.html
generated
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Swagger...</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello!</h1>
|
||||
<i>Hooray for testing...</i>
|
||||
</body>
|
||||
</html>
|
3
packages/static/test/node_modules/swagger-ui-dist/swagger-ui.css
generated
vendored
Normal file
3
packages/static/test/node_modules/swagger-ui-dist/swagger-ui.css
generated
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
html, body {
|
||||
font-weight: bold;
|
||||
}
|
1
packages/static/test/node_modules/swagger-ui-dist/test.js
generated
vendored
Normal file
1
packages/static/test/node_modules/swagger-ui-dist/test.js
generated
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
console.log('foo');
|
53
packages/static/test/push_state_test.dart
Normal file
53
packages/static/test/push_state_test.dart
Normal file
|
@ -0,0 +1,53 @@
|
|||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_static/angel_static.dart';
|
||||
import 'package:angel_test/angel_test.dart';
|
||||
import 'package:file/memory.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
main() {
|
||||
Angel app;
|
||||
MemoryFileSystem fileSystem;
|
||||
TestClient client;
|
||||
|
||||
setUp(() async {
|
||||
fileSystem = MemoryFileSystem();
|
||||
|
||||
var webDir = fileSystem.directory('web');
|
||||
await webDir.create(recursive: true);
|
||||
|
||||
var indexFile = webDir.childFile('index.html');
|
||||
await indexFile.writeAsString('index');
|
||||
|
||||
app = Angel();
|
||||
|
||||
var vDir = VirtualDirectory(
|
||||
app,
|
||||
fileSystem,
|
||||
source: webDir,
|
||||
);
|
||||
|
||||
app
|
||||
..fallback(vDir.handleRequest)
|
||||
..fallback(vDir.pushState('index.html'))
|
||||
..fallback((req, res) => 'Fallback');
|
||||
|
||||
app.logger = Logger('push_state')
|
||||
..onRecord.listen(
|
||||
(rec) {
|
||||
print(rec);
|
||||
if (rec.error != null) print(rec.error);
|
||||
if (rec.stackTrace != null) print(rec.stackTrace);
|
||||
},
|
||||
);
|
||||
|
||||
client = await connectTo(app);
|
||||
});
|
||||
|
||||
tearDown(() => client.close());
|
||||
|
||||
test('serves as fallback', () async {
|
||||
var response = await client.get('/nope');
|
||||
expect(response.body, 'index');
|
||||
});
|
||||
}
|
1
packages/static/test/sample.txt
Normal file
1
packages/static/test/sample.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Hello world
|
Loading…
Reference in a new issue