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
|
# 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)
|
[![build status](https://travis-ci.org/angel-dart/static.svg?branch=master)](https://travis-ci.org/angel-dart/static)
|
||||||
|
|
||||||
Static server middleware for Angel.
|
Static server middleware for Angel.
|
||||||
|
@ -24,7 +24,13 @@ import 'package:angel_static/angel_static.dart';
|
||||||
|
|
||||||
main() async {
|
main() async {
|
||||||
final app = new Angel();
|
final app = new Angel();
|
||||||
|
|
||||||
|
// Normal static server
|
||||||
await app.configure(new VirtualDirectory(source: new Directory('./public')));
|
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();
|
await app.startServer();
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
library angel_static;
|
library angel_static;
|
||||||
|
|
||||||
|
export 'src/cache.dart';
|
||||||
export 'src/serve_static.dart';
|
export 'src/serve_static.dart';
|
||||||
export 'src/virtual_directory.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;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A static server plug-in.
|
||||||
class VirtualDirectory implements AngelPlugin {
|
class VirtualDirectory implements AngelPlugin {
|
||||||
final bool debug;
|
final bool debug;
|
||||||
String _prefix;
|
String _prefix;
|
||||||
Directory _source;
|
Directory _source;
|
||||||
|
|
||||||
|
/// The directory to serve files from.
|
||||||
Directory get source => _source;
|
Directory get source => _source;
|
||||||
|
|
||||||
|
/// An optional callback to run before serving files.
|
||||||
final StaticFileCallback callback;
|
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;
|
final String publicPath;
|
||||||
|
|
||||||
/// If set to `true`, files will be streamed to `res.io`, instead of added to `res.buffer`.
|
/// 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)
|
if (stat.type == FileSystemEntityType.NOT_FOUND)
|
||||||
return true;
|
return true;
|
||||||
else if (stat.type == FileSystemEntityType.DIRECTORY)
|
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)
|
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) {
|
else if (stat.type == FileSystemEntityType.LINK) {
|
||||||
var link = new Link(absolute);
|
var link = new Link(absolute);
|
||||||
return await servePath(await link.resolveSymbolicLinks(), req, res);
|
return await servePath(await link.resolveSymbolicLinks(), req, res);
|
||||||
|
@ -100,7 +109,7 @@ class VirtualDirectory implements AngelPlugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> serveFile(
|
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('Sending file ${file.absolute.path}...');
|
||||||
_printDebug('MIME type for ${file.path}: ${lookupMimeType(file.path)}');
|
_printDebug('MIME type for ${file.path}: ${lookupMimeType(file.path)}');
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
|
@ -120,13 +129,13 @@ class VirtualDirectory implements AngelPlugin {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> serveDirectory(
|
Future<bool> serveDirectory(Directory directory, FileStat stat,
|
||||||
Directory directory, RequestContext req, ResponseContext res) async {
|
RequestContext req, ResponseContext res) async {
|
||||||
for (String indexFileName in indexFileNames) {
|
for (String indexFileName in indexFileNames) {
|
||||||
final index =
|
final index =
|
||||||
new File.fromUri(directory.absolute.uri.resolve(indexFileName));
|
new File.fromUri(directory.absolute.uri.resolve(indexFileName));
|
||||||
if (await index.exists()) {
|
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"
|
sdk: ">=1.19.0"
|
||||||
homepage: https://github.com/angel-dart/angel_static
|
homepage: https://github.com/angel-dart/angel_static
|
||||||
author: thosakwe <thosakwe@gmail.com>
|
author: thosakwe <thosakwe@gmail.com>
|
||||||
version: 1.1.2
|
version: 1.1.3
|
||||||
dependencies:
|
dependencies:
|
||||||
angel_framework: ^1.0.0-dev
|
angel_framework: ^1.0.0-dev
|
||||||
|
intl: ">=0.0.0 <1.0.0"
|
||||||
mime: ^0.9.3
|
mime: ^0.9.3
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
http: ^0.11.3
|
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