Updated static

This commit is contained in:
thomashii 2021-07-04 11:22:19 +08:00
parent 89d710fe28
commit cadbc1c4f7
6 changed files with 160 additions and 83 deletions

View file

@ -1,80 +1,110 @@
# 4.0.0 # Change Log
## 4.0.1
* Fixed `push_state_test` unit test failure on Windows
* Fixed NNBD related issues
* Added logging to `VirtualDirectory` and `CachingVirtualDirectory` to log exception
## 4.0.0
* Migrated to support Dart SDK 2.12.x NNBD * Migrated to support Dart SDK 2.12.x NNBD
# 3.0.0 ## 3.0.0
* Migrated to work with Dart SDK 2.12.x Non NNBD * Migrated to work with Dart SDK 2.12.x Non NNBD
# 2.1.3+2 ## 2.1.3+2
* Prepare for upcoming change to File.openRead() * Prepare for upcoming change to File.openRead()
# 2.1.3+1 ## 2.1.3+1
* Apply control flow lints. * Apply control flow lints.
# 2.1.3 ## 2.1.3
* Apply lints. * Apply lints.
* Pin to Dart `>=2.0.0 <3.0.0`. * Pin to Dart `>=2.0.0 <3.0.0`.
* Use at least version `2.0.0-rc.0` of `angel_framework`. * Use at least version `2.0.0-rc.0` of `angel_framework`.
# 2.1.2+1 ## 2.1.2+1
* Fix a typo that prevented `Range` requests from working. * Fix a typo that prevented `Range` requests from working.
# 2.1.2 ## 2.1.2
* Patch support for range+streaming in Caching server. * Patch support for range+streaming in Caching server.
# 2.1.1 ## 2.1.1
* URI-encode paths in directory listing. This produces correct URL's, always. * URI-encode paths in directory listing. This produces correct URL's, always.
# 2.1.0 ## 2.1.0
* Include support for the `Range` header. * Include support for the `Range` header.
* Use MD5 for etags, instead of a weak ETag. * Use MD5 for etags, instead of a weak ETag.
# 2.0.2 ## 2.0.2
* Fixed invalid HTML for directory listings. * Fixed invalid HTML for directory listings.
# 2.0.1 ## 2.0.1
* Remove use of `sendFile`. * Remove use of `sendFile`.
* Add a `p.isWithin` check to ensure that paths do not escape the `source` directory. * Add a `p.isWithin` check to ensure that paths do not escape the `source` directory.
* Handle `HEAD` requests. * Handle `HEAD` requests.
# 2.0.0 ## 2.0.0
* Upgrade dependencies to Angel 2 + file@5. * Upgrade dependencies to Angel 2 + file@5.
* Replace `useStream` with `useBuffer`. * Replace `useStream` with `useBuffer`.
* Remove `package:intl`, just use `HttpDate` instead. * Remove `package:intl`, just use `HttpDate` instead.
# 1.3.0+1 ## 1.3.0+1
* Dart 2 fixes. * Dart 2 fixes.
* Enable optionally writing responses to the buffer instead of streaming. * Enable optionally writing responses to the buffer instead of streaming.
# 1.3.0 ## 1.3.0
* `pushState` uses `strict` mode when `accepts` is passed. * `pushState` uses `strict` mode when `accepts` is passed.
# 1.3.0-alpha+2 ## 1.3.0-alpha+2
* Added an `accepts` option to `pushState`. * Added an `accepts` option to `pushState`.
* Added optional directory listings. * Added optional directory listings.
# 1.3.0-alpha+1 ## 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). * 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 ## 1.3.0-alpha
* Removed file transformers. * Removed file transformers.
* `VirtualDirectory` is no longer an `AngelPlugin`, and instead exposes a `handleRequest` middleware. * `VirtualDirectory` is no longer an `AngelPlugin`, and instead exposes a `handleRequest` middleware.
* Added `pushState` to `VirtualDirectory`. * Added `pushState` to `VirtualDirectory`.
# 1.2.5 ## 1.2.5
* Fixed a bug where `onlyInProduction` was not properly adhered to. * Fixed a bug where `onlyInProduction` was not properly adhered to.
* Fixed another bug where `Accept-Encoding` 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. * 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()`. * Pre-built assets can now be mass-deleted with `VirtualDirectory.cleanFromDisk()`.
Resolves [#22](https://github.com/angel-dart/static/issues/22). Resolves [#22](https://github.com/angel-dart/static/issues/22).
# 1.2.4+1 ## 1.2.4+1
Fixed a bug where `Accept-Encoding` was not properly adhered to. Fixed a bug where `Accept-Encoding` was not properly adhered to.
# 1.2.4 ## 1.2.4
Fixes https://github.com/angel-dart/angel/issues/44.
Fixes <https://github.com/angel-dart/angel/issues/44>.
* MIME types will now default to `application/octet-stream`. * 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. * When `streamToIO` is `true`, the body will only be sent gzipped if the request explicitly allows it.
# 1.2.3 ## 1.2.3
Fixed #40 and #41, which dealt with paths being improperly served when using a Fixed #40 and #41, which dealt with paths being improperly served when using a
`publicPath`. `publicPath`.

View file

@ -1,16 +1,17 @@
# angel3_static # Angel3 Static Files Service
[![version](https://img.shields.io/badge/pub-v4.0.1-brightgreen)](https://pub.dartlang.org/packages/angel3_static) [![version](https://img.shields.io/badge/pub-v4.0.1-brightgreen)](https://pub.dartlang.org/packages/angel3_static)
[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) [![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety)
[![Gitter](https://img.shields.io/gitter/room/angel_dart/discussion)](https://gitter.im/angel_dart/discussion) [![Gitter](https://img.shields.io/gitter/room/angel_dart/discussion)](https://gitter.im/angel_dart/discussion)
[![License](https://img.shields.io/github/license/dukefirehawk/angel)](https://github.com/dukefirehawk/angel/tree/angel3/packages/static/LICENSE) [![License](https://img.shields.io/github/license/dukefirehawk/angel)](https://github.com/dukefirehawk/angel/tree/angel3/packages/static/LICENSE)
This package supports serving static files such as html, css and js for [Angel3 framework](https://pub.dartlang.org/packages/angel3).
Static server infrastructure for Angel.
*Can also handle `Range` requests now, making it suitable for media streaming, ex. music, video, etc.* *Can also handle `Range` requests now, making it suitable for media streaming, ex. music, video, etc.*
# Installation ## Installation
In `pubspec.yaml`: In `pubspec.yaml`:
```yaml ```yaml
@ -18,9 +19,9 @@ dependencies:
angel3_static: ^4.0.0 angel3_static: ^4.0.0
``` ```
# Usage ## Usage
To serve files from a directory, you need to create a `VirtualDirectory`.
Keep in mind that `angel3_static` uses `package:file` instead of `dart:io`. To serve files from a directory, you need to create a `VirtualDirectory`. Keep in mind that `angel3_static` uses `package:file` instead of `dart:io`.
```dart ```dart
import 'package:angel3_framework/angel3_framework.dart'; import 'package:angel3_framework/angel3_framework.dart';
@ -46,10 +47,9 @@ void main() async {
} }
``` ```
# Push State ## 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 `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.
the user is requesting that file. This can be very useful for SPA's.
```dart ```dart
// Create VirtualDirectory as well // Create VirtualDirectory as well
@ -62,8 +62,10 @@ app.fallback(vDir.handleRequest);
app.fallback(vDir.pushState('index.html')); app.fallback(vDir.pushState('index.html'));
``` ```
# Options ## Options
The `VirtualDirectory` API accepts a few named parameters: 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 - **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`. `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']`. - **indexFileNames**: A `List<String>` of filenames that should be served as index pages. Default is `['index.html']`.

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io' show HttpDate; import 'dart:io' show HttpDate;
import 'package:angel3_framework/angel3_framework.dart'; import 'package:angel3_framework/angel3_framework.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:logging/logging.dart';
import 'virtual_directory.dart'; import 'virtual_directory.dart';
/// Returns a string representation of the given [CacheAccessLevel]. /// Returns a string representation of the given [CacheAccessLevel].
@ -18,6 +19,8 @@ String accessLevelToString(CacheAccessLevel accessLevel) {
/// A `VirtualDirectory` that also sets `Cache-Control` headers. /// A `VirtualDirectory` that also sets `Cache-Control` headers.
class CachingVirtualDirectory extends VirtualDirectory { class CachingVirtualDirectory extends VirtualDirectory {
final _log = Logger('CachingVirtualDirectory');
final Map<String, String> _etags = {}; final Map<String, String> _etags = {};
/// Either `PUBLIC` or `PRIVATE`. /// Either `PUBLIC` or `PRIVATE`.
@ -40,20 +43,20 @@ class CachingVirtualDirectory extends VirtualDirectory {
CachingVirtualDirectory(Angel app, FileSystem fileSystem, CachingVirtualDirectory(Angel app, FileSystem fileSystem,
{this.accessLevel = CacheAccessLevel.PUBLIC, {this.accessLevel = CacheAccessLevel.PUBLIC,
Directory? source, Directory? source,
bool? debug, bool debug = false,
Iterable<String>? indexFileNames, Iterable<String> indexFileNames = const ['index.html'],
this.maxAge = 0, this.maxAge = 0,
this.noCache = false, this.noCache = false,
this.onlyInProduction = false, this.onlyInProduction = false,
this.useEtags = true, this.useEtags = true,
bool? allowDirectoryListing, bool allowDirectoryListing = false,
bool useBuffer = false, bool useBuffer = false,
String? publicPath, String publicPath = '/',
Function(File file, RequestContext req, ResponseContext res)? callback}) Function(File file, RequestContext req, ResponseContext res)? callback})
: super(app, fileSystem, : super(app, fileSystem,
source: source, source: source,
indexFileNames: indexFileNames ?? ['index.html'], indexFileNames: indexFileNames,
publicPath: publicPath ?? '/', publicPath: publicPath,
callback: callback, callback: callback,
allowDirectoryListing: allowDirectoryListing, allowDirectoryListing: allowDirectoryListing,
useBuffer: useBuffer); useBuffer: useBuffer);
@ -63,27 +66,35 @@ class CachingVirtualDirectory extends VirtualDirectory {
File file, FileStat stat, RequestContext req, ResponseContext res) { File file, FileStat stat, RequestContext req, ResponseContext res) {
res.headers['accept-ranges'] = 'bytes'; res.headers['accept-ranges'] = 'bytes';
if (onlyInProduction == true && req.app!.environment.isProduction != true) { if (onlyInProduction == true && req.app?.environment.isProduction != true) {
return super.serveFile(file, stat, req, res); return super.serveFile(file, stat, req, res);
} }
if (req.headers == null) {
_log.severe('Missing headers in the RequestContext');
throw ArgumentError('Missing headers in the RequestContext');
}
var reqHeaders = req.headers!;
var shouldNotCache = noCache == true; var shouldNotCache = noCache == true;
if (!shouldNotCache) { if (!shouldNotCache) {
shouldNotCache = req.headers!.value('cache-control') == 'no-cache' || shouldNotCache = reqHeaders.value('cache-control') == 'no-cache' ||
req.headers!.value('pragma') == 'no-cache'; reqHeaders.value('pragma') == 'no-cache';
} }
if (shouldNotCache) { if (shouldNotCache) {
res.headers['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); return super.serveFile(file, stat, req, res);
} else { } else {
var ifModified = req.headers!.ifModifiedSince; var ifModified = reqHeaders.ifModifiedSince;
var ifRange = false; var ifRange = false;
try { try {
ifModified = HttpDate.parse(req.headers!.value('if-range')!); if (reqHeaders.value('if-range') != null) {
ifModified = HttpDate.parse(reqHeaders.value('if-range')!);
ifRange = true; ifRange = true;
}
} catch (_) { } catch (_) {
// Fail silently... // Fail silently...
} }
@ -97,8 +108,10 @@ class CachingVirtualDirectory extends VirtualDirectory {
setCachedHeaders(stat.modified, req, res); setCachedHeaders(stat.modified, req, res);
if (useEtags && _etags.containsKey(file.absolute.path)) { if (useEtags && _etags.containsKey(file.absolute.path)) {
if (_etags[file.absolute.path] != null) {
res.headers['ETag'] = _etags[file.absolute.path]!; res.headers['ETag'] = _etags[file.absolute.path]!;
} }
}
if (ifRange) { if (ifRange) {
// Send the 206 like normal // Send the 206 like normal
@ -111,6 +124,8 @@ class CachingVirtualDirectory extends VirtualDirectory {
return super.serveFile(file, stat, req, res); return super.serveFile(file, stat, req, res);
} }
} catch (_) { } catch (_) {
_log.severe(
'Invalid date for ${ifRange ? 'if-range' : 'if-not-modified-since'} header.');
throw AngelHttpException.badRequest( throw AngelHttpException.badRequest(
message: message:
'Invalid date for ${ifRange ? 'if-range' : 'if-not-modified-since'} header.'); 'Invalid date for ${ifRange ? 'if-range' : 'if-not-modified-since'} header.');
@ -120,18 +135,18 @@ class CachingVirtualDirectory extends VirtualDirectory {
// If-modified didn't work; try etags // If-modified didn't work; try etags
if (useEtags == true) { if (useEtags == true) {
var etagsToMatchAgainst = req.headers!['if-none-match']; var etagsToMatchAgainst = reqHeaders['if-none-match'] ?? [];
ifRange = false; ifRange = false;
if (etagsToMatchAgainst?.isNotEmpty != true) { if (etagsToMatchAgainst.isEmpty) {
etagsToMatchAgainst = req.headers!['if-range']; etagsToMatchAgainst = reqHeaders['if-range'] ?? [];
ifRange = etagsToMatchAgainst?.isNotEmpty == true; ifRange = etagsToMatchAgainst.isNotEmpty;
} }
if (etagsToMatchAgainst?.isNotEmpty == true) { if (etagsToMatchAgainst.isNotEmpty) {
var hasBeenModified = false; var hasBeenModified = false;
for (var etag in etagsToMatchAgainst!) { for (var etag in etagsToMatchAgainst) {
if (etag == '*') { if (etag == '*') {
hasBeenModified = true; hasBeenModified = true;
} else { } else {

View file

@ -3,6 +3,7 @@ import 'package:angel3_framework/angel3_framework.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:http_parser/http_parser.dart'; import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:logging/logging.dart';
import 'package:angel3_range_header/angel3_range_header.dart'; import 'package:angel3_range_header/angel3_range_header.dart';
final RegExp _param = RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?'); final RegExp _param = RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?');
@ -28,11 +29,13 @@ String _pathify(String path) {
/// A static server plug-in. /// A static server plug-in.
class VirtualDirectory { class VirtualDirectory {
String? _prefix; final _log = Logger('VirtualDirectory');
Directory? _source;
late String _prefix;
late Directory _source;
/// The directory to serve files from. /// The directory to serve files from.
Directory? get source => _source; Directory get source => _source;
/// An optional callback to run before serving files. /// An optional callback to run before serving files.
final Function(File file, RequestContext req, ResponseContext res)? callback; final Function(File file, RequestContext req, ResponseContext res)? callback;
@ -47,7 +50,7 @@ class VirtualDirectory {
final String publicPath; 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. /// 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; final bool allowDirectoryListing;
/// If `true` (default: `true`), then files will be opened as streams and piped into the request. /// If `true` (default: `true`), then files will be opened as streams and piped into the request.
/// ///
@ -77,7 +80,7 @@ class VirtualDirectory {
} }
var path = req.uri!.path.replaceAll(_straySlashes, ''); var path = req.uri!.path.replaceAll(_straySlashes, '');
if (_prefix?.isNotEmpty == true && !path.startsWith(_prefix!)) { if (_prefix.isNotEmpty == true && !path.startsWith(_prefix)) {
return Future<bool>.value(true); return Future<bool>.value(true);
} }
@ -91,7 +94,7 @@ class VirtualDirectory {
/// the view will be served. /// the view will be served.
RequestHandler pushState(String path, {Iterable? accepts}) { RequestHandler pushState(String path, {Iterable? accepts}) {
var vPath = path.replaceAll(_straySlashes, ''); var vPath = path.replaceAll(_straySlashes, '');
if (_prefix?.isNotEmpty == true) vPath = '$_prefix/$vPath'; if (_prefix.isNotEmpty == true) vPath = '$_prefix/$vPath';
return (RequestContext req, ResponseContext res) { return (RequestContext req, ResponseContext res) {
var path = req.path.replaceAll(_straySlashes, ''); var path = req.path.replaceAll(_straySlashes, '');
@ -110,22 +113,28 @@ class VirtualDirectory {
/// Writes the file at the given virtual [path] to a response. /// Writes the file at the given virtual [path] to a response.
Future<bool> servePath( Future<bool> servePath(
String path, RequestContext req, ResponseContext res) async { String path, RequestContext req, ResponseContext res) async {
if (_prefix!.isNotEmpty) { if (_prefix.isNotEmpty) {
// Only replace the *first* incidence // Only replace the *first* incidence
// Resolve: https://github.com/angel-dart/angel/issues/41 // Resolve: https://github.com/angel-dart/angel/issues/41
path = path.replaceFirst(RegExp('^' + _pathify(_prefix!)), ''); path = path.replaceFirst(RegExp('^' + _pathify(_prefix)), '');
} }
if (path.isEmpty) path = '.'; if (path.isEmpty) path = '.';
path = path.replaceAll(_straySlashes, ''); path = path.replaceAll(_straySlashes, '');
var absolute = source!.absolute.uri.resolve(path).toFilePath(); var absolute = source.absolute.uri.resolve(path).toFilePath();
var parent = source!.absolute.uri.toFilePath(); var parent = source.absolute.uri.toFilePath();
if (!p.isWithin(parent, absolute) && !p.equals(parent, absolute)) { if (!p.isWithin(parent, absolute) && !p.equals(parent, absolute)) {
return true; return true;
} }
// Replace the separator when running on Windows with file system
// detected as Linux
if (absolute.contains('\\') && fileSystem.path.separator == '/') {
absolute = absolute.replaceAll('\\', '/');
}
var stat = await fileSystem.stat(absolute); var stat = await fileSystem.stat(absolute);
return await serveStat(absolute, path, stat, req, res); return await serveStat(absolute, path, stat, req, res);
} }
@ -199,8 +208,8 @@ class VirtualDirectory {
}); });
for (var entity in entities) { for (var entity in entities) {
String? stub; String stub;
String? type; String type;
if (entity is File) { if (entity is File) {
type = '[File]'; type = '[File]';
@ -213,23 +222,24 @@ class VirtualDirectory {
stub = p.basename(entity.path); stub = p.basename(entity.path);
} else { } else {
//TODO: Handle unknown type //TODO: Handle unknown type
_log.severe('Unknown file entity. Not a file, directory or link.');
type = '[]';
stub = '';
} }
var href = stub; var href = stub;
if (relative.isNotEmpty) { if (relative.isNotEmpty) {
stub ??= '';
href = '/' + relative + '/' + stub; href = '/' + relative + '/' + stub;
} }
if (entity is Directory) { if (entity is Directory) {
if (href == null) { if (href == '') {
href = '/'; href = '/';
} else { } else {
href += '/'; href += '/';
} }
} }
href = Uri.encodeFull(href!); href = Uri.encodeFull(href);
res.write('<li><a href="$href">$type $stub</a></li>'); res.write('<li><a href="$href">$type $stub</a></li>');
} }
@ -242,12 +252,13 @@ class VirtualDirectory {
} }
void _ensureContentTypeAllowed(String mimeType, RequestContext req) { void _ensureContentTypeAllowed(String mimeType, RequestContext req) {
var value = req.headers!.value('accept'); var value = req.headers?.value('accept');
var acceptable = value == null || var acceptable = value == null ||
value.isNotEmpty != true || value.isNotEmpty != true ||
(mimeType.isNotEmpty == true && value.contains(mimeType) == true) || (mimeType.isNotEmpty == true && value.contains(mimeType) == true) ||
value.contains('*/*') == true; value.contains('*/*') == true;
if (!acceptable) { if (!acceptable) {
_log.severe('Mime type [$value] is not supported');
throw AngelHttpException( throw AngelHttpException(
UnsupportedError( UnsupportedError(
'Client requested $value, but server wanted to send $mimeType.'), 'Client requested $value, but server wanted to send $mimeType.'),
@ -262,11 +273,12 @@ class VirtualDirectory {
res.headers['accept-ranges'] = 'bytes'; res.headers['accept-ranges'] = 'bytes';
if (callback != null) { if (callback != null) {
return await req.app!.executeHandler( return await req.app?.executeHandler(
(RequestContext req, ResponseContext res) => (RequestContext req, ResponseContext res) =>
callback!(file, req, res), callback!(file, req, res),
req, req,
res); res) ??
true;
} }
var type = var type =
@ -277,14 +289,21 @@ class VirtualDirectory {
res.contentType = MediaType.parse(type); res.contentType = MediaType.parse(type);
if (useBuffer == true) res.useBuffer(); if (useBuffer == true) res.useBuffer();
if (req.headers!.value('range')?.startsWith('bytes=') != true) { if (req.headers == null) {
_log.severe('Missing headers in the RequestContext');
throw ArgumentError('Missing headers in the RequestContext');
}
var reqHeaders = req.headers!;
if (reqHeaders.value('range')?.startsWith('bytes=') != true) {
await res.streamFile(file); await res.streamFile(file);
} else { } else {
var header = RangeHeader.parse(req.headers!.value('range')!); var header = RangeHeader.parse(reqHeaders.value('range')!);
var items = RangeHeader.foldItems(header.items); var items = RangeHeader.foldItems(header.items);
var totalFileSize = await file.length();
header = RangeHeader(items); header = RangeHeader(items);
var totalFileSize = await file.length();
for (var item in header.items) { for (var item in header.items) {
var invalid = false; var invalid = false;

View file

@ -1,21 +1,22 @@
name: angel3_static name: angel3_static
description: Static server middleware for Angel. Also capable of serving Range responses. description: Static server middleware for Angel. Also capable of serving Range responses.
version: 4.0.0 version: 4.0.1
homepage: https://github.com/dukefirehawk/angel/tree/angel3/packages/static homepage: https://github.com/dukefirehawk/angel
repository: https://github.com/dukefirehawk/angel/tree/angel3/packages/static
environment: environment:
sdk: '>=2.12.0 <3.0.0' sdk: '>=2.12.0 <3.0.0'
dependencies: dependencies:
angel3_framework: ^4.0.0 angel3_framework: ^4.1.0
angel3_range_header: ^3.0.0 angel3_range_header: ^3.0.0
convert: ^3.0.0 convert: ^3.0.0
crypto: ^3.0.1 crypto: ^3.0.1
file: ^6.1.0 file: ^6.1.0
http_parser: ^4.0.0 http_parser: ^4.0.0
path: ^1.8.0 path: ^1.8.0
logging: ^1.0.1
dev_dependencies: dev_dependencies:
angel3_test: ^4.0.0 angel3_test: ^4.0.0
http: ^0.13.2 http: ^0.13.2
logging: ^1.0.1
matcher: ^0.12.10 matcher: ^0.12.10
pedantic: ^1.11.0 pedantic: ^1.11.0
test: ^1.17.4 test: ^1.17.4

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Push Test</title>
</head>
<body>
<h1>Hello!</h1>
<i>Hooray for testing...</i>
</body>
</html>