Skip to content
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ doc/api/
coverage.lcov
coverage
bin

.cursorrules
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# Changelog

## 1.1.2
- Added support for resumable downloads (RFC9110 compliant)
- Implemented range requests with single and multiple ranges
- Added support for conditional requests with ETags and Last-Modified dates
- Added proper handling of If-Range header
- Added support for suffix ranges and multipart/byteranges responses

## 1.1.1

- Adding additional crash protection
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,13 @@ However the browser will probably try to render it in browser, and not download

You can just set the right headers, but there is a handy little helper that will do it all for you.

The file handler fully supports resumable downloads (RFC9110 compliant), including:
- Range requests for partial content
- Multiple range requests with multipart responses
- Conditional requests using ETags and Last-Modified dates
- Proper handling of If-Range header
- Support for suffix ranges (e.g., last 500 bytes)

See `res.setDownload` below.

```dart
Expand Down
181 changes: 178 additions & 3 deletions lib/src/type_handlers/file_type_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,187 @@ import '../extensions/response_helpers.dart';
import 'type_handler.dart';

TypeHandler get fileTypeHandler =>
TypeHandler<File>((HttpRequest req, HttpResponse res, File file) async {
if (file.existsSync()) {
TypeHandler<File>((HttpRequest req, HttpResponse res, dynamic file) async {
file = file as File;
if (!file.existsSync()) {
throw NotFoundError();
}

final fileStats = await file.stat();
final lastModified = fileStats.modified.toUtc();
final totalSize = fileStats.size;

// Generate ETag using size and mtime (strong validator)
final etag = '"${totalSize}_${lastModified.millisecondsSinceEpoch}"';

// Set common headers
res.headers.add('accept-ranges', 'bytes');
res.headers.add('etag', etag);
res.headers.add('last-modified', HttpDate.format(lastModified));

// Handle conditional requests (If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since)
final ifMatch = req.headers.value('if-match');
final ifNoneMatch = req.headers.value('if-none-match');
final ifModifiedSince = req.headers.value('if-modified-since');
final ifUnmodifiedSince = req.headers.value('if-unmodified-since');

if (ifMatch != null &&
ifMatch != '*' &&
!ifMatch.split(',').map((e) => e.trim()).contains(etag)) {
res.statusCode = HttpStatus.preconditionFailed;
return res.close();
}

if (ifNoneMatch != null) {
if (ifNoneMatch == '*' ||
ifNoneMatch.split(',').map((e) => e.trim()).contains(etag)) {
if (req.method == 'GET' || req.method == 'HEAD') {
res.statusCode = HttpStatus.notModified;
return res.close();
} else {
res.statusCode = HttpStatus.preconditionFailed;
return res.close();
}
}
}

if (ifModifiedSince != null) {
try {
final modifiedSince = HttpDate.parse(ifModifiedSince);
if (!lastModified.isAfter(modifiedSince)) {
res.statusCode = HttpStatus.notModified;
return res.close();
}
} catch (_) {
// Invalid date format, ignore the header
}
}

if (ifUnmodifiedSince != null) {
try {
final unmodifiedSince = HttpDate.parse(ifUnmodifiedSince);
if (lastModified.isAfter(unmodifiedSince)) {
res.statusCode = HttpStatus.preconditionFailed;
return res.close();
}
} catch (_) {
// Invalid date format, ignore the header
}
}

final rangeHeader = req.headers.value('range');
if (rangeHeader == null || !rangeHeader.startsWith('bytes=')) {
res.setContentTypeFromFile(file);
await res.addStream(file.openRead());
return res.close();
}

// Check If-Range header (RFC9110 Section 13.1.3)
final ifRangeHeader = req.headers.value('if-range');
if (ifRangeHeader != null) {
bool useRanges = false;
if (ifRangeHeader.startsWith('"') || ifRangeHeader.startsWith('W/"')) {
// It's an ETag
useRanges = ifRangeHeader == etag;
} else {
// It's a Last-Modified date
try {
final rangeDate = HttpDate.parse(ifRangeHeader);
useRanges = !lastModified.isAfter(rangeDate);
} catch (_) {
useRanges = false;
}
}

if (!useRanges) {
res.setContentTypeFromFile(file);
await res.addStream(file.openRead());
return res.close();
}
}

// Parse range header (format: bytes=range1,range2,...)
final ranges = rangeHeader
.substring(6)
.split(',')
.map((range) {
final parts = range.trim().split('-');
if (parts.length != 2) return null;

final startStr = parts[0];
final endStr = parts[1];

try {
int? start, end;
if (startStr.isEmpty) {
// Suffix range (-500)
start = totalSize - int.parse(endStr);
end = totalSize - 1;
if (start < 0) start = 0;
} else {
start = int.parse(startStr);
end = endStr.isEmpty ? totalSize - 1 : int.parse(endStr);
}

// Validate range values
if (start > end || start < 0 || end >= totalSize) {
return null;
}

return _Range(start, end);
} catch (e) {
return null;
}
})
.where((range) => range != null)
.toList();

if (ranges.isEmpty) {
res.statusCode = HttpStatus.requestedRangeNotSatisfiable;
res.headers.add('content-range', 'bytes */$totalSize');
return res.close();
}

if (ranges.length == 1) {
// Single range request
final range = ranges[0]!;
final contentLength = range.end - range.start + 1;

res.statusCode = HttpStatus.partialContent;
res.setContentTypeFromFile(file);
res.headers.add(
'content-range', 'bytes ${range.start}-${range.end}/$totalSize');
res.headers.add('content-length', contentLength.toString());

await res.addStream(file.openRead(range.start, range.end + 1));
return res.close();
} else {
throw NotFoundError();
// Multiple ranges - use multipart/byteranges
final boundary = 'alfred-${DateTime.now().millisecondsSinceEpoch}';
res.statusCode = HttpStatus.partialContent;
res.headers
.add('content-type', 'multipart/byteranges; boundary=$boundary');

final contentType =
res.headers.contentType?.toString() ?? 'application/octet-stream';

for (final range in ranges) {
res.write('\r\n--$boundary\r\n');
res.write('content-type: $contentType\r\n');
res.write(
'content-range: bytes ${range!.start}-${range.end}/$totalSize\r\n\r\n');

await res.addStream(file.openRead(range.start, range.end + 1));
}

res.write('\r\n--$boundary--\r\n');
return res.close();
}
});

class _Range {
final int start;
final int end;

_Range(this.start, this.end);
}
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: alfred
description: A performant, expressjs like web server / rest api framework thats easy to use and has all the bits in one place.
version: 1.1.1
version: 1.1.2
repository: https://github.com/rknell/alfred

environment:
Expand Down
127 changes: 127 additions & 0 deletions test/alfred_test.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';

import 'package:alfred/alfred.dart';
import 'package:http/http.dart' as http;
Expand Down Expand Up @@ -36,6 +37,132 @@ void main() {
app.get('/test', (req, res) => File('test/files/image.jpg'));
final response = await http.get(Uri.parse('http://localhost:$port/test'));
expect(response.headers['content-type'], 'image/jpeg');
expect(response.bodyBytes.length, greaterThan(0));
});

test('it should support range requests for files', () async {
final file = File('test/files/image.jpg');
final totalSize = await file.length();

app.get('/test', (req, res) => file);

// Test that server advertises range support
final response = await http.get(Uri.parse('http://localhost:$port/test'));
expect(response.headers['accept-ranges'], 'bytes');
expect(response.statusCode, 200);
expect(response.bodyBytes.length, totalSize);

// Test partial content request
final rangeResponse = await http.get(
Uri.parse('http://localhost:$port/test'),
headers: {'Range': 'bytes=0-9'},
);
expect(rangeResponse.statusCode, 206);
expect(rangeResponse.headers['content-range'], 'bytes 0-9/$totalSize');
expect(rangeResponse.headers['content-length'], '10');
expect(rangeResponse.bodyBytes.length, 10);

// Test multiple ranges request
final multiRangeResponse = await http.get(
Uri.parse('http://localhost:$port/test'),
headers: {'Range': 'bytes=0-9,20-29,40-49'},
);
expect(multiRangeResponse.statusCode, 206);
expect(multiRangeResponse.headers['content-type'],
contains('multipart/byteranges'));

// Test If-Range with matching ETag
final etag = response.headers['etag'];
final ifRangeResponse = await http.get(
Uri.parse('http://localhost:$port/test'),
headers: {
'Range': 'bytes=0-9',
'If-Range': etag ?? '',
},
);
expect(ifRangeResponse.statusCode, 206);

// Test If-Range with non-matching ETag (should return full file)
final ifRangeFullResponse = await http.get(
Uri.parse('http://localhost:$port/test'),
headers: {
'Range': 'bytes=0-9',
'If-Range': '"non-matching-etag"',
},
);
expect(ifRangeFullResponse.statusCode, 200);
expect(ifRangeFullResponse.bodyBytes.length, totalSize);

// Test overlapping ranges
final overlapResponse = await http.get(
Uri.parse('http://localhost:$port/test'),
headers: {'Range': 'bytes=0-10,5-15'},
);
expect(overlapResponse.statusCode, 206);
expect(overlapResponse.headers['content-type'],
contains('multipart/byteranges'));

// Test invalid range request
final invalidResponse = await http.get(
Uri.parse('http://localhost:$port/test'),
headers: {'Range': 'bytes=10-5'},
);
expect(invalidResponse.statusCode, 416);
expect(invalidResponse.headers['content-range'], 'bytes */$totalSize');

// Test unsatisfiable range
final unsatisfiableResponse = await http.get(
Uri.parse('http://localhost:$port/test'),
headers: {'Range': 'bytes=${totalSize + 1}-${totalSize + 10}'},
);
expect(unsatisfiableResponse.statusCode, 416);
expect(
unsatisfiableResponse.headers['content-range'], 'bytes */$totalSize');
});

test('it should handle end of file range requests', () async {
final file = File('test/files/image.jpg');
final totalSize = await file.length();

app.get('/test', (req, res) => file);

// Test range request for last 10 bytes
final response = await http.get(
Uri.parse('http://localhost:$port/test'),
headers: {'Range': 'bytes=${totalSize - 10}-${totalSize - 1}'},
);
expect(response.statusCode, 206);
expect(response.headers['content-range'],
'bytes ${totalSize - 10}-${totalSize - 1}/$totalSize');
expect(response.headers['content-length'], '10');
expect(response.bodyBytes.length, 10);

// Test suffix range request
final suffixResponse = await http.get(
Uri.parse('http://localhost:$port/test'),
headers: {'Range': 'bytes=-500'},
);
expect(suffixResponse.statusCode, 206);
final expectedLength = min(500, totalSize);
expect(suffixResponse.headers['content-length'], expectedLength.toString());
});

test('it should handle open-ended range requests', () async {
final file = File('test/files/image.jpg');
final totalSize = await file.length();

app.get('/test', (req, res) => file);

// Test range request from specific byte to end
final response = await http.get(
Uri.parse('http://localhost:$port/test'),
headers: {'Range': 'bytes=10-'},
);
expect(response.statusCode, 206);
expect(response.headers['content-range'],
'bytes 10-${totalSize - 1}/$totalSize');
expect(response.headers['content-length'], '${totalSize - 10}');
expect(response.bodyBytes.length, totalSize - 10);
});

test('it should return a pdf', () async {
Expand Down
7 changes: 7 additions & 0 deletions tool/templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,13 @@ However the browser will probably try to render it in browser, and not download

You can just set the right headers, but there is a handy little helper that will do it all for you.

The file handler fully supports resumable downloads (RFC9110 compliant), including:
- Range requests for partial content
- Multiple range requests with multipart responses
- Conditional requests using ETags and Last-Modified dates
- Proper handling of If-Range header
- Support for suffix ranges (e.g., last 500 bytes)

See `res.setDownload` below.

@code example/example_file_downloads.dart
Expand Down
Loading