Skip to content

Commit 7e56247

Browse files
committed
Implement cache control support and range requests
1 parent a866cfd commit 7e56247

7 files changed

+300
-4
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 0.1.0
2+
3+
* Added HTTP cache-control support
4+
* Added HTTP range request support for partial content
5+
16
## 0.0.4
27

38
* Update library metadata

README.md

+67
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77

88
A simple handler for the Shelf ecosystem to serve files from Flutter assets.
99

10+
## Features
11+
- Serves files from Flutter assets
12+
- Support for default documents (like index.html)
13+
- Content type detection
14+
- Optional caching support with customizable max-age
15+
- Optional support for HTTP range requests
16+
1017
## Usage
1118

1219
Bind as root handler:
@@ -67,3 +74,63 @@ void main() {
6774
io.serve(app, 'localhost', 8080);
6875
}
6976
```
77+
78+
## Advanced Usage
79+
80+
### Caching
81+
82+
Enable browser caching for better performance:
83+
84+
```dart
85+
import 'package:shelf/shelf_io.dart' as io;
86+
import 'package:shelf_flutter_asset/shelf_flutter_asset.dart';
87+
88+
void main() {
89+
var assetHandler = createAssetHandler(
90+
defaultDocument: 'index.html',
91+
enableCaching: true, // Enable caching
92+
maxAge: 3600, // Cache for 1 hour (in seconds)
93+
);
94+
95+
io.serve(assetHandler, 'localhost', 8080);
96+
}
97+
```
98+
99+
### Range Requests
100+
101+
Support HTTP range requests for media files:
102+
103+
```dart
104+
import 'package:shelf/shelf_io.dart' as io;
105+
import 'package:shelf_flutter_asset/shelf_flutter_asset.dart';
106+
107+
void main() {
108+
var assetHandler = createAssetHandler(
109+
defaultDocument: 'index.html',
110+
enableRangeRequests: true, // Enable range requests for media streaming
111+
);
112+
113+
io.serve(assetHandler, 'localhost', 8080);
114+
}
115+
```
116+
117+
### Combined Features
118+
119+
Use all features together:
120+
121+
```dart
122+
import 'package:shelf/shelf_io.dart' as io;
123+
import 'package:shelf_flutter_asset/shelf_flutter_asset.dart';
124+
125+
void main() {
126+
var assetHandler = createAssetHandler(
127+
defaultDocument: 'index.html',
128+
rootPath: 'assets/web',
129+
enableCaching: true,
130+
maxAge: 86400, // Cache for 1 day
131+
enableRangeRequests: true,
132+
);
133+
134+
io.serve(assetHandler, 'localhost', 8080);
135+
}
136+
```

lib/shelf_flutter_asset.dart

+66-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:io';
2+
import 'dart:math';
23

34
import 'package:flutter/services.dart';
45
import 'package:mime/mime.dart';
@@ -8,6 +9,9 @@ import 'package:shelf/shelf.dart';
89
/// The default resolver for MIME types.
910
final _defaultMimeTypeResolver = MimeTypeResolver();
1011

12+
/// Default cache duration (1 hour)
13+
const _defaultMaxAge = 3600;
14+
1115
/// Creates a Shelf [Handler] that serves files from Flutter assets.
1216
///
1317
/// If requested resource does not exist and [defaultDocument] is specified,
@@ -20,10 +24,18 @@ final _defaultMimeTypeResolver = MimeTypeResolver();
2024
/// If your assets are not in the standard `assets` directory,
2125
/// or you want to share only subtree of the assets path structure,
2226
/// you may use [rootPath] argument to set the root directory for your handler.
27+
///
28+
/// Set [enableCaching] to true to add cache-control headers with [maxAge] seconds
29+
/// (defaults to 1 hour).
30+
///
31+
/// Set [enableRangeRequests] to true to support HTTP range requests for partial content.
2332
Handler createAssetHandler(
2433
{String? defaultDocument,
2534
String rootPath = 'assets',
26-
MimeTypeResolver? contentTypeResolver}) {
35+
MimeTypeResolver? contentTypeResolver,
36+
bool enableCaching = false,
37+
int maxAge = _defaultMaxAge,
38+
bool enableRangeRequests = false}) {
2739
final mimeResolver = contentTypeResolver ?? _defaultMimeTypeResolver;
2840

2941
return (Request request) async {
@@ -52,6 +64,16 @@ Handler createAssetHandler(
5264
if (contentType != null) HttpHeaders.contentTypeHeader: contentType,
5365
};
5466

67+
// Add cache control headers if enabled
68+
if (enableCaching) {
69+
headers[HttpHeaders.cacheControlHeader] = 'max-age=$maxAge, public';
70+
}
71+
72+
// Handle range requests if enabled
73+
if (enableRangeRequests && request.headers.containsKey('range')) {
74+
return _handleRangeRequest(request, body, headers);
75+
}
76+
5577
return Response.ok((request.method == 'HEAD') ? null : body,
5678
headers: headers);
5779
};
@@ -66,3 +88,46 @@ Future<Uint8List?> _loadResource(String key) async {
6688

6789
return null;
6890
}
91+
92+
/// Handles HTTP range requests by parsing the Range header and returning
93+
/// the appropriate partial content response.
94+
Response _handleRangeRequest(
95+
Request request, Uint8List body, Map<String, String> headers) {
96+
final rangeHeader = request.headers['range']!;
97+
final match = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(rangeHeader);
98+
99+
if (match == null) {
100+
return Response(416, headers: headers); // Range Not Satisfiable
101+
}
102+
103+
final startStr = match.group(1);
104+
final endStr = match.group(2);
105+
106+
int start = startStr!.isEmpty ? 0 : int.parse(startStr);
107+
int end = endStr!.isEmpty ? body.length - 1 : int.parse(endStr);
108+
109+
// Validate the range
110+
if (start >= body.length || end >= body.length || start > end) {
111+
return Response(416, headers: {
112+
...headers,
113+
'content-range': 'bytes */${body.length}',
114+
});
115+
}
116+
117+
// Limit the length to the actual body size
118+
end = min(end, body.length - 1);
119+
120+
final length = end - start + 1;
121+
final rangeBody = body.sublist(start, end + 1);
122+
123+
final rangeHeaders = {
124+
...headers,
125+
HttpHeaders.contentLengthHeader: '$length',
126+
'content-range': 'bytes $start-$end/${body.length}',
127+
'accept-ranges': 'bytes',
128+
};
129+
130+
return Response(206, // Partial Content
131+
body: (request.method == 'HEAD') ? null : rangeBody,
132+
headers: rangeHeaders);
133+
}

pubspec.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: shelf_flutter_asset
22
description: A simple handler for the Shelf ecosystem to serve files from Flutter assets.
3-
version: 0.0.4
3+
version: 0.1.0
44
homepage: https://github.com/r8/shelf_flutter_asset
55
repository: https://github.com/r8/shelf_flutter_asset
66
issue_tracker: https://github.com/r8/shelf_flutter_asset/issues

test/cache_support_test.dart

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:shelf/shelf.dart';
3+
import 'package:shelf_flutter_asset/shelf_flutter_asset.dart';
4+
5+
import 'test_util.dart';
6+
7+
void main() {
8+
setUpAll(() async {
9+
setUpAssets();
10+
});
11+
12+
group('Cache Support', () {
13+
test('cache-control headers not present by default', () async {
14+
final handler = createAssetHandler();
15+
final request = makeRequest(path: '/assets/index.html');
16+
final response = await handler(request);
17+
18+
expect(response.statusCode, equals(200));
19+
expect(response.headers['cache-control'], isNull);
20+
});
21+
22+
test('cache-control headers present when enabled', () async {
23+
final handler = createAssetHandler(enableCaching: true, maxAge: 3600);
24+
final request = makeRequest(path: '/assets/index.html');
25+
final response = await handler(request);
26+
27+
expect(response.statusCode, equals(200));
28+
expect(response.headers['cache-control'], equals('max-age=3600, public'));
29+
});
30+
31+
test('cache-control headers use custom maxAge', () async {
32+
final handler = createAssetHandler(enableCaching: true, maxAge: 86400);
33+
final request = makeRequest(path: '/assets/index.html');
34+
final response = await handler(request);
35+
36+
expect(response.statusCode, equals(200));
37+
expect(response.headers['cache-control'], equals('max-age=86400, public'));
38+
});
39+
40+
test('cache-control headers are not added for 404 responses', () async {
41+
final handler = createAssetHandler(enableCaching: true);
42+
final request = makeRequest(path: '/assets/not-found.html');
43+
final response = await handler(request);
44+
45+
expect(response.statusCode, equals(404));
46+
expect(response.headers['cache-control'], isNull);
47+
});
48+
});
49+
}

test/range_request_test.dart

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:shelf/shelf.dart';
6+
import 'package:shelf_flutter_asset/shelf_flutter_asset.dart';
7+
8+
import 'test_util.dart';
9+
10+
void main() {
11+
setUpAll(() async {
12+
setUpAssets();
13+
});
14+
15+
group('Range Requests', () {
16+
test('range request support disabled by default', () async {
17+
final handler = createAssetHandler();
18+
final request = makeRequest(
19+
path: '/assets/index.html',
20+
headers: {'range': 'bytes=0-10'},
21+
);
22+
final response = await handler(request);
23+
24+
expect(response.statusCode, equals(200));
25+
expect(response.headers['accept-ranges'], isNull);
26+
expect(response.headers['content-range'], isNull);
27+
});
28+
29+
test('valid range request returns partial content', () async {
30+
final handler = createAssetHandler(enableRangeRequests: true);
31+
final request = makeRequest(
32+
path: '/assets/index.html',
33+
headers: {'range': 'bytes=0-10'},
34+
);
35+
final response = await handler(request);
36+
37+
expect(response.statusCode, equals(206));
38+
expect(response.headers['accept-ranges'], equals('bytes'));
39+
expect(response.headers['content-range'], isNotNull);
40+
expect(response.headers['content-length'], equals('11'));
41+
42+
// Verify content is correct
43+
final body = await response.readAsString();
44+
expect(body.length, equals(11));
45+
});
46+
47+
test('invalid range format returns 416', () async {
48+
final handler = createAssetHandler(enableRangeRequests: true);
49+
final request = makeRequest(
50+
path: '/assets/index.html',
51+
headers: {'range': 'invalid-range-format'},
52+
);
53+
final response = await handler(request);
54+
55+
expect(response.statusCode, equals(416));
56+
});
57+
58+
test('range outside file bounds returns 416', () async {
59+
final handler = createAssetHandler(enableRangeRequests: true);
60+
final request = makeRequest(
61+
path: '/assets/index.html',
62+
headers: {'range': 'bytes=100000-200000'},
63+
);
64+
final response = await handler(request);
65+
66+
expect(response.statusCode, equals(416));
67+
expect(response.headers['content-range'], contains('*'));
68+
});
69+
70+
test('open-ended range request works', () async {
71+
final handler = createAssetHandler(enableRangeRequests: true);
72+
final request = makeRequest(
73+
path: '/assets/index.html',
74+
headers: {'range': 'bytes=10-'},
75+
);
76+
final response = await handler(request);
77+
78+
expect(response.statusCode, equals(206));
79+
expect(response.headers['accept-ranges'], equals('bytes'));
80+
expect(response.headers['content-range'], isNotNull);
81+
});
82+
83+
test('head request with range returns no body', () async {
84+
final handler = createAssetHandler(enableRangeRequests: true);
85+
final request = makeRequest(
86+
method: 'HEAD',
87+
path: '/assets/index.html',
88+
headers: {'range': 'bytes=0-10'},
89+
);
90+
final response = await handler(request);
91+
92+
expect(response.statusCode, equals(206));
93+
expect(response.headers['accept-ranges'], equals('bytes'));
94+
expect(response.headers['content-range'], isNotNull);
95+
expect(response.headers['content-length'], equals('11'));
96+
97+
// Verify body is empty for HEAD request
98+
final body = await response.read().toList();
99+
expect(body, isEmpty);
100+
});
101+
});
102+
}

test/test_util.dart

+10-2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ setUpAssets() {
2121
});
2222
}
2323

24-
Request makeRequest({String method = 'GET', String path = '/'}) {
25-
return Request(method, Uri(scheme: 'http', host: 'localhost', path: path));
24+
Request makeRequest({
25+
String method = 'GET',
26+
String path = '/',
27+
Map<String, String>? headers,
28+
}) {
29+
return Request(
30+
method,
31+
Uri(scheme: 'http', host: 'localhost', path: path),
32+
headers: headers,
33+
);
2634
}

0 commit comments

Comments
 (0)