Added caching
This commit is contained in:
parent
3ef26d9ada
commit
e6d9ffa79b
7 changed files with 298 additions and 9 deletions
|
@ -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();
|
||||
}
|
||||
```
|
||||
|
|
|
@ -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
177
lib/src/cache.dart
Normal 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 }
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
24
test/cache_sample.dart
Normal 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
71
test/cache_test.dart
Normal 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')));
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue