Added caching

This commit is contained in:
thosakwe 2017-02-26 19:19:34 -05:00
parent 3ef26d9ada
commit e6d9ffa79b
7 changed files with 298 additions and 9 deletions

View file

@ -1,6 +1,6 @@
# angel_static
[![version 1.1.2](https://img.shields.io/badge/pub-1.1.2-brightgreen.svg)](https://pub.dartlang.org/packages/angel_static)
[![version 1.1.3](https://img.shields.io/badge/pub-1.1.3-brightgreen.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 middleware for Angel.
@ -24,7 +24,13 @@ import 'package:angel_static/angel_static.dart';
main() async {
final app = new Angel();
// Normal static server
await app.configure(new VirtualDirectory(source: new Directory('./public')));
// Send Cache-Control, ETag, etc. as well
await app.configure(new CachingVirtualDirectory(source: new Directory('./public')));
await app.startServer();
}
```

View file

@ -1,4 +1,5 @@
library angel_static;
export 'src/cache.dart';
export 'src/serve_static.dart';
export 'src/virtual_directory.dart';

177
lib/src/cache.dart Normal file
View file

@ -0,0 +1,177 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:angel_framework/angel_framework.dart';
import 'package:crypto/crypto.dart';
import 'package:intl/intl.dart';
import 'package:mime/mime.dart';
import 'virtual_directory.dart';
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<int> 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());
}
}
/// 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 new ArgumentError('Unrecognized cache access level: $accessLevel');
}
}
/// A static server plug-in that also sets `Cache-Control` headers.
class CachingVirtualDirectory extends VirtualDirectory {
final Map<String, String> _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;
/// 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;
/// If `false` (default: `true`), ETags will be generated via MD5 hash.
final bool useWeakEtags;
/// The `max-age` for `Cache-Control`.
final int maxAge;
CachingVirtualDirectory(
{this.accessLevel: CacheAccessLevel.PUBLIC,
Directory source,
bool debug,
this.hash,
Iterable<String> indexFileNames,
this.maxAge: 0,
this.noCache: false,
this.onlyInProduction: false,
this.useEtags: true,
this.useWeakEtags: true,
String publicPath,
StaticFileCallback callback,
bool streamToIO: false})
: super(
source: source,
debug: debug == true,
indexFileNames: indexFileNames ?? ['index.html'],
publicPath: publicPath ?? '/',
callback: callback,
streamToIO: streamToIO == true);
@override
Future<bool> serveFile(
File file, FileStat stat, RequestContext req, ResponseContext res) {
if (onlyInProduction == true && req.app.isProduction == true) {
return super.serveFile(file, stat, req, res);
}
if (noCache == true) {
res.headers[HttpHeaders.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];
if (etags?.isNotEmpty == true) {
bool hasBeenModified = false;
for (var etag in etags) {
if (etag == '*')
hasBeenModified = true;
else {
hasBeenModified = _etags.containsKey(file.absolute.path) &&
_etags[file.absolute.path] == etag;
}
}
if (hasBeenModified) {
res.statusCode = HttpStatus.NOT_MODIFIED;
setCachedHeaders(file, stat, req, res);
return new Future.value(false);
}
}
}
if (req.headers[HttpHeaders.IF_MODIFIED_SINCE] != null) {
try {
var ifModifiedSince = _fmt.parse(req.headers
.value(HttpHeaders.IF_MODIFIED_SINCE)
.replaceAll('GMT', '')
.trim());
if (ifModifiedSince.compareTo(stat.changed) > 0) {
res.statusCode = HttpStatus.NOT_MODIFIED;
setCachedHeaders(file, stat, req, res);
if (_etags.containsKey(file.absolute.path))
res.headers[HttpHeaders.ETAG] = _etags[file.absolute.path];
return new Future.value(false);
}
} catch (_) {
throw new AngelHttpException.badRequest(
message: 'Invalid date for If-Modified-Since header.');
}
}
return file.readAsBytes().then((buf) {
var etag = _etags[file.absolute.path] =
generateEtag(buf, weak: useWeakEtags != false);
res.headers
..[HttpHeaders.ETAG] = etag
..[HttpHeaders.CONTENT_TYPE] = lookupMimeType(file.path);
setCachedHeaders(file, stat, req, res);
if (useWeakEtags == false) {
res
..statusCode = 200
..willCloseItself = false
..buffer.add(buf)
..end();
return new Future.value(false);
}
return super.serveFile(file, stat, req, res);
});
}
}
void setCachedHeaders(
File file, FileStat stat, RequestContext req, ResponseContext res) {
var privacy = accessLevelToString(accessLevel ?? CacheAccessLevel.PUBLIC);
var expiry = new DateTime.now()..add(new Duration(seconds: maxAge ?? 0));
res.headers
..[HttpHeaders.CACHE_CONTROL] = '$privacy, max-age=${maxAge ?? 0}'
..[HttpHeaders.EXPIRES] = formatDateForHttp(expiry)
..[HttpHeaders.LAST_MODIFIED] = formatDateForHttp(stat.changed);
}
}
enum CacheAccessLevel { PUBLIC, PRIVATE }

View file

@ -25,13 +25,22 @@ String _pathify(String path) {
return p;
}
/// A static server plug-in.
class VirtualDirectory implements AngelPlugin {
final bool debug;
String _prefix;
Directory _source;
/// The directory to serve files from.
Directory get source => _source;
/// An optional callback to run before serving files.
final StaticFileCallback callback;
final List<String> indexFileNames;
/// Filenames to be resolved within directories as indices.
final Iterable<String> indexFileNames;
/// 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`.
@ -89,9 +98,9 @@ class VirtualDirectory implements AngelPlugin {
if (stat.type == FileSystemEntityType.NOT_FOUND)
return true;
else if (stat.type == FileSystemEntityType.DIRECTORY)
return await serveDirectory(new Directory(absolute), req, res);
return await serveDirectory(new Directory(absolute), stat, req, res);
else if (stat.type == FileSystemEntityType.FILE)
return await serveFile(new File(absolute), req, res);
return await serveFile(new File(absolute), stat, req, res);
else if (stat.type == FileSystemEntityType.LINK) {
var link = new Link(absolute);
return await servePath(await link.resolveSymbolicLinks(), req, res);
@ -100,7 +109,7 @@ class VirtualDirectory implements AngelPlugin {
}
Future<bool> serveFile(
File file, RequestContext req, ResponseContext res) async {
File file, FileStat stat, RequestContext req, ResponseContext res) async {
_printDebug('Sending file ${file.absolute.path}...');
_printDebug('MIME type for ${file.path}: ${lookupMimeType(file.path)}');
res.statusCode = 200;
@ -120,13 +129,13 @@ class VirtualDirectory implements AngelPlugin {
return false;
}
Future<bool> serveDirectory(
Directory directory, RequestContext req, ResponseContext res) async {
Future<bool> 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));
if (await index.exists()) {
return await serveFile(index, req, res);
return await serveFile(index, stat, req, res);
}
}

View file

@ -4,9 +4,10 @@ environment:
sdk: ">=1.19.0"
homepage: https://github.com/angel-dart/angel_static
author: thosakwe <thosakwe@gmail.com>
version: 1.1.2
version: 1.1.3
dependencies:
angel_framework: ^1.0.0-dev
intl: ">=0.0.0 <1.0.0"
mime: ^0.9.3
dev_dependencies:
http: ^0.11.3

24
test/cache_sample.dart Normal file
View file

@ -0,0 +1,24 @@
import 'dart:io';
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_static/angel_static.dart';
main() async {
Angel app;
Directory testDir = new Directory('test');
app = new Angel(debug: true);
await app.configure(new CachingVirtualDirectory(
source: testDir,
maxAge: 350,
onlyInProduction: false,
// useWeakEtags: false,
//publicPath: '/virtual',
indexFileNames: ['index.txt']));
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}');
}

71
test/cache_test.dart Normal file
View file

@ -0,0 +1,71 @@
import 'dart:io';
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_static/angel_static.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');
String url;
Client client = new Client();
setUp(() async {
app = new Angel(debug: true);
await app.configure(new CachingVirtualDirectory(
source: testDir, maxAge: 350, onlyInProduction: false,
//publicPath: '/virtual',
indexFileNames: ['index.txt']));
app.get('*', 'Fallback');
app.dumpTree(showMatchers: true);
await app.startServer(InternetAddress.LOOPBACK_IP_V4, 0);
url = "http://${app.httpServer.address.host}:${app.httpServer.port}";
});
tearDown(() async {
if (app.httpServer != null) await app.httpServer.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(
[
HttpHeaders.ETAG,
HttpHeaders.CACHE_CONTROL,
HttpHeaders.EXPIRES,
HttpHeaders.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:
formatDateForHttp(new DateTime.now()..add(new Duration(days: 365)))
});
print('Response status: ${response.statusCode}');
expect(response.statusCode, equals(304));
expect(
[
HttpHeaders.CACHE_CONTROL,
HttpHeaders.EXPIRES,
HttpHeaders.LAST_MODIFIED
],
everyElement(predicate(
response.headers.containsKey, 'contained in response headers')));
});
}