Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Caching and range requests #17

Merged
merged 3 commits into from
Mar 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

* Added HTTP cache-control support
* Added HTTP range request support for partial content
* Added support for conditional requests with If-Modified-Since

## 0.0.4

Expand Down
43 changes: 3 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
A simple handler for the Shelf ecosystem to serve files from Flutter assets.

## Features

- Serves files from Flutter assets
- Support for default documents (like index.html)
- Content type detection
- Optional caching support with customizable max-age
- Optional support for HTTP range requests
- Support for HTTP range requests
- Support for conditional requests with If-Modified-Since

## Usage

Expand Down Expand Up @@ -95,42 +97,3 @@ void main() {
io.serve(assetHandler, 'localhost', 8080);
}
```

### Range Requests

Support HTTP range requests for media files:

```dart
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_flutter_asset/shelf_flutter_asset.dart';

void main() {
var assetHandler = createAssetHandler(
defaultDocument: 'index.html',
enableRangeRequests: true, // Enable range requests for media streaming
);

io.serve(assetHandler, 'localhost', 8080);
}
```

### Combined Features

Use all features together:

```dart
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_flutter_asset/shelf_flutter_asset.dart';

void main() {
var assetHandler = createAssetHandler(
defaultDocument: 'index.html',
rootPath: 'assets/web',
enableCaching: true,
maxAge: 86400, // Cache for 1 day
enableRangeRequests: true,
);

io.serve(assetHandler, 'localhost', 8080);
}
```
33 changes: 28 additions & 5 deletions lib/shelf_flutter_asset.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import 'package:mime/mime.dart';
import 'package:path/path.dart' as p;
import 'package:shelf/shelf.dart';

/// Application start time used for Last-Modified headers
final _appStartTime = DateTime.now();

/// The default resolver for MIME types.
final _defaultMimeTypeResolver = MimeTypeResolver();

Expand All @@ -28,14 +31,12 @@ const _defaultMaxAge = 3600;
/// Set [enableCaching] to true to add cache-control headers with [maxAge] seconds
/// (defaults to 1 hour).
///
/// Set [enableRangeRequests] to true to support HTTP range requests for partial content.
Handler createAssetHandler(
{String? defaultDocument,
String rootPath = 'assets',
MimeTypeResolver? contentTypeResolver,
bool enableCaching = false,
int maxAge = _defaultMaxAge,
bool enableRangeRequests = false}) {
int maxAge = _defaultMaxAge}) {
final mimeResolver = contentTypeResolver ?? _defaultMimeTypeResolver;

return (Request request) async {
Expand All @@ -59,18 +60,40 @@ Handler createAssetHandler(

final contentType = mimeResolver.lookup(key);

// Use a fixed last-modified timestamp for static assets
// This is based on the application start time
final lastModified = _appStartTime;
final lastModifiedHeader = HttpDate.format(lastModified);

final headers = {
HttpHeaders.contentLengthHeader: '${body.length}',
if (contentType != null) HttpHeaders.contentTypeHeader: contentType,
HttpHeaders.lastModifiedHeader: lastModifiedHeader,
};

// Check if the resource has been modified since the client's version
final ifModifiedSince = request.headers[HttpHeaders.ifModifiedSinceHeader];
if (ifModifiedSince != null) {
try {
final clientDate = HttpDate.parse(ifModifiedSince);
// Compare dates, treating them as equal if they are within 1 second
// This helps with test reliability and HTTP date precision issues
final difference = lastModified.difference(clientDate).inSeconds.abs();
if (difference < 1) {
return Response.notModified(headers: headers);
}
} catch (_) {
// Invalid date format, ignore header
}
}

// Add cache control headers if enabled
if (enableCaching) {
headers[HttpHeaders.cacheControlHeader] = 'max-age=$maxAge, public';
}

// Handle range requests if enabled
if (enableRangeRequests && request.headers.containsKey('range')) {
// Handle range requests
if (request.headers.containsKey('range')) {
return _handleRangeRequest(request, body, headers);
}

Expand Down
79 changes: 79 additions & 0 deletions test/conditional_get_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:shelf_flutter_asset/shelf_flutter_asset.dart';

import 'test_util.dart';

void main() {
setUpAll(() async {
setUpAssets();
});

group('Conditional GET Requests', () {
test('includes Last-Modified header in response', () async {
final handler = createAssetHandler();
final request = makeRequest(
path: '/index.html',
);
final response = await handler(request);

expect(response.statusCode, equals(200));
expect(response.headers[HttpHeaders.lastModifiedHeader], isNotNull);
expect(response.headers[HttpHeaders.lastModifiedHeader], isNotEmpty);
});

test('returns 304 Not Modified for valid If-Modified-Since header',
() async {
// Create a single handler instance for all requests in this test
// to ensure we get the same Last-Modified timestamp
final handler = createAssetHandler();

// First get the Last-Modified value
final initialRequest = makeRequest(path: '/index.html');
final initialResponse = await handler(initialRequest);
final lastModified =
initialResponse.headers[HttpHeaders.lastModifiedHeader]!;

// Then make a conditional request with that same timestamp
final conditionalRequest = makeRequest(
path: '/index.html',
headers: {HttpHeaders.ifModifiedSinceHeader: lastModified},
);

final conditionalResponse = await handler(conditionalRequest);

expect(conditionalResponse.statusCode, equals(304));
expect(await conditionalResponse.read().toList(), isEmpty);
});

test('returns 200 OK for If-Modified-Since header with old date', () async {
final handler = createAssetHandler();

// Use a date from the past
final oldDate = HttpDate.format(DateTime(2000, 1, 1));

final request = makeRequest(
path: '/index.html',
headers: {HttpHeaders.ifModifiedSinceHeader: oldDate},
);
final response = await handler(request);

expect(response.statusCode, equals(200));
expect(await response.read().toList(), isNotEmpty);
});

test('ignores invalid If-Modified-Since header', () async {
final handler = createAssetHandler();

final request = makeRequest(
path: '/index.html',
headers: {HttpHeaders.ifModifiedSinceHeader: 'invalid-date-format'},
);
final response = await handler(request);

expect(response.statusCode, equals(200));
expect(await response.read().toList(), isNotEmpty);
});
});
}
23 changes: 5 additions & 18 deletions test/range_request_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,14 @@ void main() {
});

group('Range Requests', () {
test('range request support disabled by default', () async {
test('range request returns valid range', () async {
final handler = createAssetHandler();
final request = makeRequest(
path: '/index.html',
headers: {'range': 'bytes=0-10'},
);
final response = await handler(request);

expect(response.statusCode, equals(200));
expect(response.headers['accept-ranges'], isNull);
expect(response.headers['content-range'], isNull);
});

test('valid range request returns partial content', () async {
final handler = createAssetHandler(enableRangeRequests: true);
final request = makeRequest(
path: '/index.html',
headers: {'range': 'bytes=0-10'},
);
final response = await handler(request);

expect(response.statusCode, equals(206));
expect(response.headers['accept-ranges'], equals('bytes'));
expect(response.headers['content-range'], isNotNull);
Expand All @@ -41,7 +28,7 @@ void main() {
});

test('invalid range format returns 416', () async {
final handler = createAssetHandler(enableRangeRequests: true);
final handler = createAssetHandler();
final request = makeRequest(
path: '/index.html',
headers: {'range': 'invalid-range-format'},
Expand All @@ -52,7 +39,7 @@ void main() {
});

test('range outside file bounds returns 416', () async {
final handler = createAssetHandler(enableRangeRequests: true);
final handler = createAssetHandler();
final request = makeRequest(
path: '/index.html',
headers: {'range': 'bytes=100000-200000'},
Expand All @@ -64,7 +51,7 @@ void main() {
});

test('open-ended range request works', () async {
final handler = createAssetHandler(enableRangeRequests: true);
final handler = createAssetHandler();
final request = makeRequest(
path: '/index.html',
headers: {'range': 'bytes=10-'},
Expand All @@ -77,7 +64,7 @@ void main() {
});

test('head request with range returns no body', () async {
final handler = createAssetHandler(enableRangeRequests: true);
final handler = createAssetHandler();
final request = makeRequest(
method: 'HEAD',
path: '/index.html',
Expand Down